chore: rename packages/ to crates/
Move all 29 workspace members from packages/<name>/ to crates/<name>/. Updates: workspace Cargo.toml (members + path deps), justfile, root CLAUDE.md, scripts/build/CARGO_INSTALL.md, docs/architecture/crates.md (renamed from packages.md), structural references in docs/architecture and docs/configuration, per-crate CLAUDE.md self-references. Historical plans, reports, and building/ docs are left untouched. No behavior change; just check-all stays green and fermata tests pass.
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
//! Integration test for dirigate bridge connecting to Dirigent ACP Server.
|
||||
//!
|
||||
//! This test validates the end-to-end flow:
|
||||
//! 1. Start Dirigent ACP Server on a test port (or assume one is running)
|
||||
//! 2. Use dirigate bridge to connect via stdio
|
||||
//! 3. Verify connection and communication works
|
||||
//!
|
||||
//! ## Running the tests
|
||||
//!
|
||||
//! ### Quick tests (no server required)
|
||||
//!
|
||||
//! These tests verify the bridge binary can be spawned and shows help:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run all non-ignored tests
|
||||
//! cargo test --package dirigate --test dirigent_bridge_test
|
||||
//!
|
||||
//! # Or run specific tests
|
||||
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_spawns
|
||||
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_protocol
|
||||
//! ```
|
||||
//!
|
||||
//! ### Full integration test (requires running server)
|
||||
//!
|
||||
//! The main integration test requires a Dirigent ACP Server running on http://localhost:3001/acp.
|
||||
//!
|
||||
//! **Step 1:** Start the Dirigent server
|
||||
//! ```bash
|
||||
//! # Option A: Via web UI
|
||||
//! cargo run --package web
|
||||
//! # Then enable ACP Server in the UI at Configuration > ACP Server
|
||||
//!
|
||||
//! # Option B: Via dirigent_core directly (if implemented)
|
||||
//! # cargo run --package dirigent_core -- acp-server --port 3001
|
||||
//! ```
|
||||
//!
|
||||
//! **Step 2:** Run the integration test
|
||||
//! ```bash
|
||||
//! cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_to_dirigent -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ### Programmatic server test (future)
|
||||
//!
|
||||
//! This test demonstrates starting a minimal ACP server programmatically for testing.
|
||||
//! It's currently marked as ignored until the full implementation is complete.
|
||||
//!
|
||||
//! ```bash
|
||||
//! cargo test --package dirigate --test dirigent_bridge_test test_bridge_with_programmatic_server -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! ## Test overview
|
||||
//!
|
||||
//! - `test_conductor_bridge_spawns` - Verifies the bridge binary exists and can show help (always runs)
|
||||
//! - `test_conductor_bridge_protocol` - Basic protocol verification (always runs)
|
||||
//! - `test_conductor_bridge_to_dirigent` - Full end-to-end test with real server (ignored by default)
|
||||
//! - `test_bridge_with_programmatic_server` - Test with programmatically started server (ignored, future)
|
||||
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Path to the dirigate binary (debug build).
|
||||
fn conductor_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"
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper to send a JSON-RPC request to the bridge and read the response.
|
||||
///
|
||||
/// This function writes a JSON-RPC request to stdin and reads responses from stdout.
|
||||
/// It skips notifications (messages with "method" but no "id") and returns only
|
||||
/// the response matching the request ID.
|
||||
async fn send_bridge_request(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
stdout: &mut BufReader<tokio::process::ChildStdout>,
|
||||
request: Value,
|
||||
timeout_secs: u64,
|
||||
) -> Result<Value> {
|
||||
// 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
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if start.elapsed() > std::time::Duration::from_secs(timeout_secs) {
|
||||
anyhow::bail!("Timeout waiting for response to request {:?}", request_id);
|
||||
}
|
||||
|
||||
let mut line = String::new();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(2),
|
||||
stdout.read_line(&mut line),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(0)) => {
|
||||
anyhow::bail!("EOF reached before receiving response");
|
||||
}
|
||||
Ok(Ok(_)) => {
|
||||
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() {
|
||||
eprintln!("Skipping notification: {:?}", msg.get("method"));
|
||||
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 {
|
||||
eprintln!(
|
||||
"Got response with id {:?}, expected {:?}",
|
||||
msg.get("id"),
|
||||
id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// No ID means we're looking for any response
|
||||
return Ok(msg);
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
anyhow::bail!("IO error reading from stdout: {}", e);
|
||||
}
|
||||
Err(_) => {
|
||||
// Timeout on this read, loop and try again
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that dirigate bridge can be spawned and shows help.
|
||||
#[tokio::test]
|
||||
async fn test_conductor_bridge_spawns() -> Result<()> {
|
||||
let binary = conductor_binary();
|
||||
if !binary.exists() {
|
||||
eprintln!(
|
||||
"Conductor binary not found at {:?}. Run 'cargo build --package dirigate' first.",
|
||||
binary
|
||||
);
|
||||
return Ok(()); // Skip test if binary doesn't exist
|
||||
}
|
||||
|
||||
// Test that --help works
|
||||
let output = Command::new(&binary)
|
||||
.args(&["bridge", "--help"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"conductor bridge --help should succeed"
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
assert!(
|
||||
stdout.contains("bridge") || stdout.contains("Bridge"),
|
||||
"Help output should mention bridge. Got: {}",
|
||||
stdout
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("server") || stdout.contains("ACP"),
|
||||
"Help output should mention server or ACP"
|
||||
);
|
||||
|
||||
println!("✓ Conductor bridge binary spawns successfully");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that dirigate bridge can connect to Dirigent ACP Server.
|
||||
///
|
||||
/// **Prerequisites:**
|
||||
/// - A Dirigent ACP Server must be running on http://localhost:8080/acp
|
||||
/// - Start the live server with ACP enabled (Configuration > ACP Server)
|
||||
///
|
||||
/// **Note:** By default, ACP is nested at /acp on the main Dioxus server (port 8080).
|
||||
/// If you configured ACP to run on a separate port, adjust the server_url below.
|
||||
///
|
||||
/// **To run this test:**
|
||||
/// ```bash
|
||||
/// # Start server first (enable ACP Server in Configuration UI)
|
||||
/// dx serve --package web
|
||||
///
|
||||
/// # Then run test
|
||||
/// cargo test --package dirigate --test dirigent_bridge_test test_conductor_bridge_to_dirigent -- --ignored --nocapture
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires running Dirigent ACP Server on localhost:8080"]
|
||||
async fn test_conductor_bridge_to_dirigent() -> Result<()> {
|
||||
let binary = conductor_binary();
|
||||
if !binary.exists() {
|
||||
eprintln!(
|
||||
"Conductor binary not found at {:?}. Run 'cargo build --package dirigate' first.",
|
||||
binary
|
||||
);
|
||||
return Ok(()); // Skip test if binary doesn't exist
|
||||
}
|
||||
|
||||
let server_url = "http://localhost:8080/acp";
|
||||
|
||||
println!("Starting dirigate bridge to {}", server_url);
|
||||
|
||||
// Start dirigate bridge process
|
||||
let mut dirigate = Command::new(&binary)
|
||||
.args(&["bridge", "--server-url", server_url, "--verbose"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped()) // Capture stderr for debugging
|
||||
.spawn()?;
|
||||
|
||||
// Get handles for stdin/stdout
|
||||
let mut stdin = dirigate.stdin.take().expect("Failed to get stdin");
|
||||
let stdout = dirigate.stdout.take().expect("Failed to get stdout");
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
println!("Bridge process started, sending initialize request");
|
||||
|
||||
// Send initialize request
|
||||
let initialize_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {
|
||||
"tools": {
|
||||
"supported": true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let response = send_bridge_request(&mut stdin, &mut reader, initialize_request, 10).await?;
|
||||
|
||||
// Verify we got a valid JSON-RPC response
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert_eq!(response["id"], 1);
|
||||
|
||||
// Check for result or error
|
||||
if response.get("result").is_some() {
|
||||
println!("✓ Successfully connected to Dirigent server via dirigate bridge");
|
||||
println!(
|
||||
"Initialize response: {}",
|
||||
serde_json::to_string_pretty(&response)?
|
||||
);
|
||||
|
||||
// Verify the response has expected fields
|
||||
let result = &response["result"];
|
||||
assert!(
|
||||
result.get("protocolVersion").is_some()
|
||||
|| result.get("agentCapabilities").is_some()
|
||||
|| result.get("client_id").is_some(),
|
||||
"Initialize result should have protocol version, capabilities, or client_id"
|
||||
);
|
||||
} else if response.get("error").is_some() {
|
||||
eprintln!("✗ Got error response from server:");
|
||||
eprintln!("{}", serde_json::to_string_pretty(&response)?);
|
||||
anyhow::bail!("Server returned error: {:?}", response["error"]);
|
||||
} else {
|
||||
anyhow::bail!("Response has neither result nor error: {:?}", response);
|
||||
}
|
||||
|
||||
// Optional: Test creating a session
|
||||
println!("\nTesting session creation...");
|
||||
let session_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "session/new",
|
||||
"id": 2,
|
||||
"params": {
|
||||
"cwd": ".",
|
||||
"mcpServers": []
|
||||
}
|
||||
});
|
||||
|
||||
let session_response = send_bridge_request(&mut stdin, &mut reader, session_request, 10).await?;
|
||||
|
||||
assert_eq!(session_response["jsonrpc"], "2.0");
|
||||
assert_eq!(session_response["id"], 2);
|
||||
|
||||
if session_response.get("result").is_some() {
|
||||
let session_id = session_response["result"]["sessionId"]
|
||||
.as_str()
|
||||
.expect("sessionId should be a string");
|
||||
println!("✓ Created session: {}", session_id);
|
||||
println!(
|
||||
"Session response: {}",
|
||||
serde_json::to_string_pretty(&session_response)?
|
||||
);
|
||||
} else if session_response.get("error").is_some() {
|
||||
eprintln!("Got error creating session:");
|
||||
eprintln!("{}", serde_json::to_string_pretty(&session_response)?);
|
||||
// Don't fail the test - session creation might not be implemented yet
|
||||
println!("⚠ Session creation returned error (may not be implemented yet)");
|
||||
}
|
||||
|
||||
// Clean up
|
||||
dirigate.kill().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test dirigate bridge with a simulated server response (unit test style).
|
||||
///
|
||||
/// This test doesn't require a real server - it just verifies the bridge
|
||||
/// can be spawned and interacts correctly at the protocol level.
|
||||
#[tokio::test]
|
||||
async fn test_conductor_bridge_protocol() -> Result<()> {
|
||||
let binary = conductor_binary();
|
||||
if !binary.exists() {
|
||||
return Ok(()); // Skip if binary not built
|
||||
}
|
||||
|
||||
// This test would ideally mock the HTTP server, but that's complex.
|
||||
// For now, we just verify the binary exists and can be invoked.
|
||||
// A full mock server test could be added later.
|
||||
|
||||
let output = Command::new(&binary)
|
||||
.args(&["bridge", "--help"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
assert!(output.status.success());
|
||||
|
||||
println!("✓ Bridge protocol test passed (basic verification)");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that demonstrates how to start a minimal Dirigent ACP Server programmatically.
|
||||
///
|
||||
/// **NOTE:** This test is currently a placeholder showing the structure.
|
||||
/// It requires the full dirigent_acp_api to be implemented with a way to
|
||||
/// start the server programmatically.
|
||||
#[tokio::test]
|
||||
#[ignore = "Requires full dirigent_acp_api implementation"]
|
||||
async fn test_bridge_with_programmatic_server() -> Result<()> {
|
||||
use dirigent_acp_api::{create_acp_server_router, AcpServerConfig, AcpServerState, NoOpConnectorOperations};
|
||||
|
||||
// Create server configuration
|
||||
let config = AcpServerConfig::enabled().set_port(0); // Use random port
|
||||
|
||||
// Create server state
|
||||
let state = AcpServerState::new(config.clone());
|
||||
|
||||
// Create the router with no-op connector operations
|
||||
let router = create_acp_server_router(state, NoOpConnectorOperations);
|
||||
|
||||
// Start server in background
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let server_addr = listener.local_addr()?;
|
||||
let server_url = format!("http://{}/acp", server_addr);
|
||||
|
||||
println!("Started test server at {}", server_url);
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
|
||||
// Give server time to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Now test the bridge
|
||||
let binary = conductor_binary();
|
||||
if !binary.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut dirigate = Command::new(&binary)
|
||||
.args(&["bridge", "--server-url", &server_url])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let mut stdin = dirigate.stdin.take().unwrap();
|
||||
let stdout = dirigate.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Send initialize
|
||||
let init_request = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": 1,
|
||||
"clientCapabilities": {}
|
||||
}
|
||||
});
|
||||
|
||||
let response = send_bridge_request(&mut stdin, &mut reader, init_request, 5).await?;
|
||||
|
||||
assert_eq!(response["jsonrpc"], "2.0");
|
||||
assert!(
|
||||
response.get("result").is_some() || response.get("error").is_some(),
|
||||
"Should get a valid response"
|
||||
);
|
||||
|
||||
println!("✓ Bridge successfully connected to programmatic server");
|
||||
|
||||
dirigate.kill().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Integration tests for fixture loading and validation.
|
||||
|
||||
use dirigate::fixture::{load_and_validate, load_fixture, load_fixtures_from_dir, validate_fixture};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_fixture_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_valid_basic_fixture() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
assert_eq!(fixture.version, "0.1");
|
||||
assert_eq!(fixture.sessions.len(), 1);
|
||||
assert_eq!(fixture.sessions[0].id, "session-1");
|
||||
assert_eq!(fixture.sessions[0].title, "Basic Test Session");
|
||||
assert_eq!(fixture.sessions[0].participants.len(), 2);
|
||||
assert_eq!(fixture.sessions[0].messages.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_valid_complex_fixture() {
|
||||
let path = test_fixture_path("valid_complex.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
assert_eq!(fixture.version, "0.1");
|
||||
assert_eq!(fixture.sessions.len(), 2);
|
||||
|
||||
// Check first session
|
||||
let session1 = &fixture.sessions[0];
|
||||
assert_eq!(session1.id, "session-chat");
|
||||
assert_eq!(session1.participants.len(), 3);
|
||||
assert_eq!(session1.messages.len(), 4);
|
||||
|
||||
// Check message metadata
|
||||
let msg3 = &session1.messages[2];
|
||||
assert!(msg3.metadata.is_some());
|
||||
|
||||
// Check second session with behavior override
|
||||
let session2 = &fixture.sessions[1];
|
||||
assert_eq!(session2.id, "session-debug");
|
||||
assert!(session2.behavior.is_some());
|
||||
|
||||
// Check responders config
|
||||
assert_eq!(fixture.responders.keyword_map.len(), 2);
|
||||
assert!(fixture.responders.random.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_valid_basic_fixture() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
// Should validate successfully
|
||||
validate_fixture(&fixture).expect("Fixture validation failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_valid_complex_fixture() {
|
||||
let path = test_fixture_path("valid_complex.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
// Should validate successfully
|
||||
validate_fixture(&fixture).expect("Fixture validation failed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_and_validate_valid() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load and validate");
|
||||
|
||||
assert_eq!(fixture.version, "0.1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_invalid_version() {
|
||||
let path = test_fixture_path("invalid_version.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let result = validate_fixture(&fixture);
|
||||
assert!(result.is_err());
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid version"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_and_validate_invalid_version() {
|
||||
let path = test_fixture_path("invalid_version.yaml");
|
||||
let result = load_and_validate(&path).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Invalid version"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_duplicate_session_ids() {
|
||||
let path = test_fixture_path("invalid_duplicate_session.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let result = validate_fixture(&fixture);
|
||||
assert!(result.is_err());
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Duplicate session ID"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_bad_yaml() {
|
||||
let path = test_fixture_path("invalid_bad_yaml.yaml");
|
||||
let result = load_fixture(&path).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Failed to parse YAML"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_validate_bad_timestamp() {
|
||||
let path = test_fixture_path("invalid_bad_timestamp.yaml");
|
||||
let fixture = load_fixture(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let result = validate_fixture(&fixture);
|
||||
assert!(result.is_err());
|
||||
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("invalid ISO8601 timestamp"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_nonexistent_file() {
|
||||
let path = test_fixture_path("does_not_exist.yaml");
|
||||
let result = load_fixture(&path).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err();
|
||||
assert!(err.to_string().contains("Failed to read file"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_fixtures_from_directory() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures");
|
||||
|
||||
let fixtures = load_fixtures_from_dir(&dir).await.expect("Failed to load fixtures from directory");
|
||||
|
||||
// Should load the 2 valid fixtures, skipping the invalid ones
|
||||
assert_eq!(fixtures.len(), 2);
|
||||
|
||||
// Verify we got the right fixtures
|
||||
let ids: Vec<_> = fixtures.iter()
|
||||
.flat_map(|f| f.sessions.iter().map(|s| s.id.as_str()))
|
||||
.collect();
|
||||
|
||||
assert!(ids.contains(&"session-1") || ids.contains(&"session-chat"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_load_fixtures_from_empty_directory() {
|
||||
// Create a temporary empty directory
|
||||
let temp_dir = std::env::temp_dir().join("dirigent_test_empty");
|
||||
tokio::fs::create_dir_all(&temp_dir).await.ok();
|
||||
|
||||
let fixtures = load_fixtures_from_dir(&temp_dir).await.expect("Failed to load from empty directory");
|
||||
|
||||
// Should return empty vector, not error
|
||||
assert_eq!(fixtures.len(), 0);
|
||||
|
||||
// Cleanup
|
||||
tokio::fs::remove_dir_all(&temp_dir).await.ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_parent_references() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
// Check that parent_id references are valid
|
||||
let session = &fixture.sessions[0];
|
||||
let msg2 = &session.messages[1];
|
||||
|
||||
assert_eq!(msg2.parent_id, Some("msg-1".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_session_behavior_overrides() {
|
||||
let path = test_fixture_path("valid_complex.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
// Second session should have behavior overrides
|
||||
let session2 = &fixture.sessions[1];
|
||||
assert!(session2.behavior.is_some());
|
||||
|
||||
let behavior = session2.behavior.as_ref().unwrap();
|
||||
assert!(behavior.responder.is_some());
|
||||
assert!(behavior.streaming.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_participant_kinds() {
|
||||
use dirigate::fixture::ParticipantKind;
|
||||
|
||||
let path = test_fixture_path("valid_complex.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let session = &fixture.sessions[0];
|
||||
let participant_kinds: Vec<_> = session.participants.iter()
|
||||
.map(|p| &p.kind)
|
||||
.collect();
|
||||
|
||||
assert!(participant_kinds.contains(&&ParticipantKind::User));
|
||||
assert!(participant_kinds.contains(&&ParticipantKind::Assistant));
|
||||
assert!(participant_kinds.contains(&&ParticipantKind::System));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_message_roles() {
|
||||
use dirigate::fixture::MessageRole;
|
||||
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let messages = &fixture.sessions[0].messages;
|
||||
assert_eq!(messages[0].role, MessageRole::User);
|
||||
assert_eq!(messages[1].role, MessageRole::Assistant);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_responder_strategies() {
|
||||
use dirigate::fixture::ResponderStrategy;
|
||||
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
assert_eq!(fixture.responders.default_strategy, ResponderStrategy::Echo);
|
||||
|
||||
let path2 = test_fixture_path("valid_complex.yaml");
|
||||
let fixture2 = load_and_validate(&path2).await.expect("Failed to load fixture");
|
||||
|
||||
assert_eq!(fixture2.responders.default_strategy, ResponderStrategy::Keywords);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_streaming_configuration() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
assert!(fixture.streaming.enabled);
|
||||
assert_eq!(fixture.streaming.tokens_per_chunk, 5);
|
||||
assert_eq!(fixture.streaming.chunk_interval_ms, 100);
|
||||
assert_eq!(fixture.streaming.jitter_ms, Some(20));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_keyword_map() {
|
||||
let path = test_fixture_path("valid_basic.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
assert_eq!(fixture.responders.keyword_map.get("hello"), Some(&"Hi there!".to_string()));
|
||||
assert_eq!(fixture.responders.keyword_map.get("help"), Some(&"I can assist you with various tasks.".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_random_config() {
|
||||
let path = test_fixture_path("valid_complex.yaml");
|
||||
let fixture = load_and_validate(&path).await.expect("Failed to load fixture");
|
||||
|
||||
let random_config = fixture.responders.random.as_ref().expect("Missing random config");
|
||||
assert_eq!(random_config.seed, 42);
|
||||
assert_eq!(random_config.corpus.len(), 3);
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
version: "0.1"
|
||||
|
||||
sessions:
|
||||
- id: "session-1"
|
||||
title: "Test Session"
|
||||
created_at: "not-a-valid-date"
|
||||
participants: []
|
||||
messages: []
|
||||
|
||||
responders:
|
||||
keyword_map: {}
|
||||
default_strategy: echo
|
||||
|
||||
streaming:
|
||||
enabled: false
|
||||
tokens_per_chunk: 1
|
||||
chunk_interval_ms: 100
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
version: "0.1"
|
||||
sessions:
|
||||
- id: "session-1
|
||||
title: "Unclosed quote
|
||||
this is not valid yaml: [broken
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
version: "0.1"
|
||||
|
||||
sessions:
|
||||
- id: "session-1"
|
||||
title: "First Session"
|
||||
created_at: "2024-01-01T00:00:00Z"
|
||||
participants: []
|
||||
messages: []
|
||||
- id: "session-1"
|
||||
title: "Duplicate Session"
|
||||
created_at: "2024-01-01T00:00:00Z"
|
||||
participants: []
|
||||
messages: []
|
||||
|
||||
responders:
|
||||
keyword_map: {}
|
||||
default_strategy: echo
|
||||
|
||||
streaming:
|
||||
enabled: false
|
||||
tokens_per_chunk: 1
|
||||
chunk_interval_ms: 100
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
version: "0.2"
|
||||
|
||||
sessions:
|
||||
- id: "session-1"
|
||||
title: "Test Session"
|
||||
created_at: "2024-01-01T00:00:00Z"
|
||||
participants: []
|
||||
messages: []
|
||||
|
||||
responders:
|
||||
keyword_map: {}
|
||||
default_strategy: echo
|
||||
|
||||
streaming:
|
||||
enabled: false
|
||||
tokens_per_chunk: 1
|
||||
chunk_interval_ms: 100
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
version: "0.1"
|
||||
|
||||
sessions:
|
||||
- id: "session-1"
|
||||
title: "Basic Test Session"
|
||||
created_at: "2024-01-01T00:00:00Z"
|
||||
participants:
|
||||
- id: "user-1"
|
||||
kind: user
|
||||
display_name: "Test User"
|
||||
- id: "assistant-1"
|
||||
kind: assistant
|
||||
display_name: "Test Assistant"
|
||||
messages:
|
||||
- id: "msg-1"
|
||||
session_id: "session-1"
|
||||
role: user
|
||||
content: "Hello, assistant!"
|
||||
created_at: "2024-01-01T00:00:01Z"
|
||||
- id: "msg-2"
|
||||
session_id: "session-1"
|
||||
role: assistant
|
||||
content: "Hello! How can I help you today?"
|
||||
created_at: "2024-01-01T00:00:02Z"
|
||||
parent_id: "msg-1"
|
||||
|
||||
responders:
|
||||
keyword_map:
|
||||
hello: "Hi there!"
|
||||
help: "I can assist you with various tasks."
|
||||
default_strategy: echo
|
||||
|
||||
streaming:
|
||||
enabled: true
|
||||
tokens_per_chunk: 5
|
||||
chunk_interval_ms: 100
|
||||
jitter_ms: 20
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
version: "0.1"
|
||||
|
||||
sessions:
|
||||
- id: "session-chat"
|
||||
title: "Complex Chat Session"
|
||||
created_at: "2024-01-01T10:00:00Z"
|
||||
participants:
|
||||
- id: "user-1"
|
||||
kind: user
|
||||
display_name: "Alice"
|
||||
- id: "assistant-1"
|
||||
kind: assistant
|
||||
display_name: "Bob AI"
|
||||
- id: "system-1"
|
||||
kind: system
|
||||
messages:
|
||||
- id: "msg-1"
|
||||
session_id: "session-chat"
|
||||
role: system
|
||||
content: "System initialized"
|
||||
created_at: "2024-01-01T10:00:00Z"
|
||||
- id: "msg-2"
|
||||
session_id: "session-chat"
|
||||
role: user
|
||||
content: "What's the weather?"
|
||||
created_at: "2024-01-01T10:00:05Z"
|
||||
parent_id: "msg-1"
|
||||
- id: "msg-3"
|
||||
session_id: "session-chat"
|
||||
role: assistant
|
||||
content: "Let me check that for you."
|
||||
created_at: "2024-01-01T10:00:06Z"
|
||||
parent_id: "msg-2"
|
||||
metadata:
|
||||
thinking: true
|
||||
confidence: 0.95
|
||||
- id: "msg-4"
|
||||
session_id: "session-chat"
|
||||
role: assistant
|
||||
content: "The weather is sunny with a temperature of 72°F."
|
||||
created_at: "2024-01-01T10:00:08Z"
|
||||
parent_id: "msg-3"
|
||||
|
||||
- id: "session-debug"
|
||||
title: "Debug Session"
|
||||
created_at: "2024-01-02T00:00:00Z"
|
||||
participants:
|
||||
- id: "dev-1"
|
||||
kind: user
|
||||
display_name: "Developer"
|
||||
- id: "tool-1"
|
||||
kind: tool
|
||||
display_name: "Debugger"
|
||||
messages:
|
||||
- id: "debug-1"
|
||||
session_id: "session-debug"
|
||||
role: user
|
||||
content: "Run diagnostic"
|
||||
created_at: "2024-01-02T00:00:01Z"
|
||||
behavior:
|
||||
responder: fixture_only
|
||||
streaming:
|
||||
enabled: false
|
||||
tokens_per_chunk: 1
|
||||
chunk_interval_ms: 50
|
||||
|
||||
responders:
|
||||
keyword_map:
|
||||
weather: "The weather is sunny."
|
||||
time: "The current time is noon."
|
||||
default_strategy: keywords
|
||||
random:
|
||||
seed: 42
|
||||
corpus:
|
||||
- "Random response 1"
|
||||
- "Random response 2"
|
||||
- "Random response 3"
|
||||
|
||||
streaming:
|
||||
enabled: true
|
||||
tokens_per_chunk: 10
|
||||
chunk_interval_ms: 50
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Integration tests for the ACP JSON-RPC server.
|
||||
//!
|
||||
//! These tests verify that the server correctly handles JSON-RPC requests
|
||||
//! over HTTP and returns proper responses.
|
||||
|
||||
use dirigate::acp::model::{JsonRpcError, JsonRpcRequest, JsonRpcResponse};
|
||||
use dirigate::acp::server::AcpServer;
|
||||
use dirigate::fixture::types::*;
|
||||
use dirigate::{MockerConfig, MockerState};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Helper to create a minimal test fixture with a template session.
|
||||
fn create_test_fixture_with_template() -> Fixture {
|
||||
Fixture {
|
||||
version: "0.1".to_string(),
|
||||
sessions: vec![Session {
|
||||
id: "test-template".to_string(),
|
||||
title: "Test Template Session".to_string(),
|
||||
created_at: "2025-01-01T00:00:00Z".to_string(),
|
||||
participants: vec![
|
||||
Participant {
|
||||
id: "user-1".to_string(),
|
||||
kind: ParticipantKind::User,
|
||||
display_name: Some("Test User".to_string()),
|
||||
},
|
||||
Participant {
|
||||
id: "assistant-1".to_string(),
|
||||
kind: ParticipantKind::Assistant,
|
||||
display_name: Some("Test Assistant".to_string()),
|
||||
},
|
||||
],
|
||||
messages: vec![],
|
||||
behavior: None,
|
||||
}],
|
||||
responders: Responders {
|
||||
keyword_map: HashMap::new(),
|
||||
default_strategy: ResponderStrategy::Echo,
|
||||
random: None,
|
||||
},
|
||||
streaming: Streaming {
|
||||
enabled: true,
|
||||
tokens_per_chunk: 5,
|
||||
chunk_interval_ms: 100,
|
||||
jitter_ms: Some(10),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_server_initialize_request() {
|
||||
// Create state
|
||||
let config = MockerConfig::default();
|
||||
let fixtures = create_test_fixture_with_template();
|
||||
let state = Arc::new(MockerState::new(config, fixtures));
|
||||
|
||||
// Create server on a random port
|
||||
let server = AcpServer::new(state.clone(), 0);
|
||||
|
||||
// Start server in background
|
||||
tokio::spawn(async move {
|
||||
let _ = server.serve().await;
|
||||
});
|
||||
|
||||
// Give server time to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Note: We can't actually test the HTTP endpoint without binding to a known port
|
||||
// This test verifies the server compiles and runs without panicking
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jsonrpc_initialize_response_format() {
|
||||
// Create a mock initialize request
|
||||
let request = JsonRpcRequest::new(
|
||||
"initialize",
|
||||
Some(serde_json::json!({
|
||||
"protocol_version": "0.1",
|
||||
"client_capabilities": {}
|
||||
})),
|
||||
1,
|
||||
);
|
||||
|
||||
// Verify request serialization
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"method\":\"initialize\""));
|
||||
assert!(json.contains("\"id\":1"));
|
||||
|
||||
// Parse it back
|
||||
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.method, "initialize");
|
||||
assert_eq!(parsed.jsonrpc, "2.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jsonrpc_session_new_response_format() {
|
||||
// Create a mock session/new request
|
||||
let request = JsonRpcRequest::new(
|
||||
"session/new",
|
||||
Some(serde_json::json!({
|
||||
"template_id": "test-template"
|
||||
})),
|
||||
2,
|
||||
);
|
||||
|
||||
// Verify request serialization
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"method\":\"session/new\""));
|
||||
assert!(json.contains("\"template_id\":\"test-template\""));
|
||||
assert!(json.contains("\"id\":2"));
|
||||
|
||||
// Parse it back
|
||||
let parsed: JsonRpcRequest = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.method, "session/new");
|
||||
assert_eq!(parsed.jsonrpc, "2.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jsonrpc_error_response_format() {
|
||||
// Create an error response
|
||||
let error = JsonRpcError::method_not_found("unknown_method");
|
||||
let response = JsonRpcResponse::error(error, Some(serde_json::json!(1)));
|
||||
|
||||
// Verify response serialization
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"error\""));
|
||||
assert!(json.contains("-32601")); // Method not found code
|
||||
assert!(!json.contains("\"result\"")); // Should not have result field
|
||||
|
||||
// Parse it back
|
||||
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.error.is_some());
|
||||
assert!(parsed.result.is_none());
|
||||
assert_eq!(parsed.error.unwrap().code, -32601);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_jsonrpc_success_response_format() {
|
||||
// Create a success response
|
||||
let result = serde_json::json!({
|
||||
"session_id": "test-session-123"
|
||||
});
|
||||
let response = JsonRpcResponse::success(result, Some(serde_json::json!(1)));
|
||||
|
||||
// Verify response serialization
|
||||
let json = serde_json::to_string(&response).unwrap();
|
||||
assert!(json.contains("\"jsonrpc\":\"2.0\""));
|
||||
assert!(json.contains("\"result\""));
|
||||
assert!(json.contains("\"session_id\":\"test-session-123\""));
|
||||
assert!(!json.contains("\"error\"")); // Should not have error field
|
||||
|
||||
// Parse it back
|
||||
let parsed: JsonRpcResponse = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.result.is_some());
|
||||
assert!(parsed.error.is_none());
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
//! 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<tokio::process::ChildStdout>,
|
||||
request: Value,
|
||||
) -> anyhow::Result<Value> {
|
||||
// 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user