160 lines
6.1 KiB
Rust
160 lines
6.1 KiB
Rust
|
|
//! Cross-binary integration test.
|
||
|
|
//!
|
||
|
|
//! Spawns `aether-recall-mcp` and `aether-ask` against a shared data
|
||
|
|
//! directory. Issues a record via the MCP transport, then a query via
|
||
|
|
//! the HTTP transport. Asserts the second process sees what the
|
||
|
|
//! first wrote. If this fails, the system is not an integrated whole.
|
||
|
|
|
||
|
|
use serde_json::json;
|
||
|
|
use std::io::{Read, Write};
|
||
|
|
use std::net::TcpStream;
|
||
|
|
use std::path::{Path, PathBuf};
|
||
|
|
use std::process::{Child, Command, Stdio};
|
||
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||
|
|
|
||
|
|
/// Find the per-target binaries. We resolve via the per-target
|
||
|
|
/// `CARGO_BIN_EXE_<name>` for our own crate's bin, and via the
|
||
|
|
/// workspace target/release path for sibling crates' bins.
|
||
|
|
fn bin_mcp() -> PathBuf {
|
||
|
|
let env = format!("CARGO_BIN_EXE_{}", "aether-recall-mcp");
|
||
|
|
PathBuf::from(std::env::var(&env).expect("CARGO_BIN_EXE_aether-recall-mcp"))
|
||
|
|
}
|
||
|
|
fn bin_ask() -> PathBuf {
|
||
|
|
// Sibling-crate binaries are not exposed via CARGO_BIN_EXE_ in this
|
||
|
|
// crate's tests; they sit in workspace target/{debug,release}.
|
||
|
|
// We use the absolute workspace path because the test's CWD isn't
|
||
|
|
// guaranteed to be the workspace root.
|
||
|
|
// ../target/debug/aether-ask from this crate's tests/ dir at
|
||
|
|
// crates/aether-recall-mcp/tests/ is too fragile; instead use CARGO_MANIFEST_DIR.
|
||
|
|
// But we don't know which profile. Probe both.
|
||
|
|
let manifest = std::env::var("CARGO_MANIFEST_DIR")
|
||
|
|
.expect("CARGO_MANIFEST_DIR");
|
||
|
|
let crate_dir = PathBuf::from(manifest); // crates/aether-recall-mcp
|
||
|
|
let workspace_dir = crate_dir
|
||
|
|
.parent().and_then(|p| p.parent()).expect("workspace dir")
|
||
|
|
.to_path_buf();
|
||
|
|
for profile in ["release", "debug"] {
|
||
|
|
let p = workspace_dir.join("target").join(profile).join("aether-ask");
|
||
|
|
if p.exists() { return p; }
|
||
|
|
}
|
||
|
|
panic!("aether-ask binary not found under {workspace_dir:?}/target/{{release,debug}}");
|
||
|
|
}
|
||
|
|
|
||
|
|
struct McpClient {
|
||
|
|
child: Child,
|
||
|
|
}
|
||
|
|
impl McpClient {
|
||
|
|
fn spawn(data_dir: &Path) -> Self {
|
||
|
|
let mut cmd = Command::new(bin_mcp());
|
||
|
|
cmd.env("AETHER_RECALL_DIR", data_dir)
|
||
|
|
.stdin(Stdio::piped())
|
||
|
|
.stdout(Stdio::piped())
|
||
|
|
.stderr(Stdio::null());
|
||
|
|
Self { child: cmd.spawn().expect("spawn mcp") }
|
||
|
|
}
|
||
|
|
fn send(&mut self, req: serde_json::Value) -> serde_json::Value {
|
||
|
|
let stdin = self.child.stdin.as_mut().unwrap();
|
||
|
|
writeln!(stdin, "{}", serde_json::to_string(&req).unwrap()).unwrap();
|
||
|
|
stdin.flush().unwrap();
|
||
|
|
let stdout = self.child.stdout.as_mut().unwrap();
|
||
|
|
let mut buf = Vec::new();
|
||
|
|
let mut byte = [0u8; 1];
|
||
|
|
while stdout.read(&mut byte).unwrap() > 0 {
|
||
|
|
buf.push(byte[0]);
|
||
|
|
if byte[0] == b'\n' { break; }
|
||
|
|
}
|
||
|
|
serde_json::from_slice(&buf).unwrap()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
impl Drop for McpClient {
|
||
|
|
fn drop(&mut self) { let _ = self.child.kill(); }
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn mcp_record_then_mcp_lookup_returns_hit() {
|
||
|
|
let dir = tmp();
|
||
|
|
let mut m = McpClient::spawn(&dir);
|
||
|
|
m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":1,"method":"tools/call",
|
||
|
|
"params":{"name":"record_outcome","arguments":{
|
||
|
|
"failure":"E0308 canonical test","resolution":"use .to_string()","worked":true
|
||
|
|
}}
|
||
|
|
}));
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":2,"method":"tools/call",
|
||
|
|
"params":{"name":"lookup_resolution","arguments":{"failure":"E0308 canonical test"}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"hit\":true"), "got: {content}");
|
||
|
|
assert!(content.contains("use .to_string()"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn ask_returns_value_after_mcp_recorded() {
|
||
|
|
let dir = tmp();
|
||
|
|
|
||
|
|
// 1. MCP records a fact in process A
|
||
|
|
{
|
||
|
|
let mut m = McpClient::spawn(&dir);
|
||
|
|
m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":1,"method":"tools/call",
|
||
|
|
"params":{"name":"record_outcome","arguments":{
|
||
|
|
"failure":"cross-binary integration",
|
||
|
|
"resolution":"verified by separate processes",
|
||
|
|
"worked":true
|
||
|
|
}}
|
||
|
|
}));
|
||
|
|
} // process A drops → killed
|
||
|
|
|
||
|
|
// 2. ASK spins up in process B, hits the SAME data dir, asserts hit
|
||
|
|
let port = ephemeral_port();
|
||
|
|
let mut ask_child = Command::new(bin_ask())
|
||
|
|
.env("AETHER_RECALL_DIR", &dir)
|
||
|
|
.env("AETHER_ASK_PORT", port.to_string())
|
||
|
|
.env("OLLAMA_PORT", "1") // unreachable → fast neural-fail path
|
||
|
|
.stdin(Stdio::null())
|
||
|
|
.stdout(Stdio::null())
|
||
|
|
.stderr(Stdio::null())
|
||
|
|
.spawn()
|
||
|
|
.expect("spawn ask");
|
||
|
|
|
||
|
|
let addr = format!("127.0.0.1:{}", port);
|
||
|
|
for _ in 0..60 {
|
||
|
|
if TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(100)).is_ok() {
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
std::thread::sleep(Duration::from_millis(50));
|
||
|
|
}
|
||
|
|
|
||
|
|
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||
|
|
s.set_read_timeout(Some(Duration::from_secs(3))).ok();
|
||
|
|
let body = json!({"question":"cross-binary integration"}).to_string();
|
||
|
|
let req = format!(
|
||
|
|
"POST /ask HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||
|
|
body.len(), body
|
||
|
|
);
|
||
|
|
s.write_all(req.as_bytes()).unwrap();
|
||
|
|
let mut buf = Vec::new();
|
||
|
|
s.read_to_end(&mut buf).ok();
|
||
|
|
let text = String::from_utf8_lossy(&buf).to_string();
|
||
|
|
let (_head, body) = text.split_once("\r\n\r\n").unwrap_or(("", &text));
|
||
|
|
let v: serde_json::Value = serde_json::from_str(body).expect("json");
|
||
|
|
let _ = ask_child.kill();
|
||
|
|
|
||
|
|
assert_eq!(v["source"].as_str(), Some("deterministic"),
|
||
|
|
"ASK must hit the cached MCP record across processes; got: {v:?}");
|
||
|
|
assert_eq!(v["answer"].as_str(), Some("verified by separate processes"));
|
||
|
|
}
|
||
|
|
|
||
|
|
fn tmp() -> PathBuf {
|
||
|
|
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||
|
|
let p = std::env::temp_dir().join(format!("cross-bin-it-{stamp}"));
|
||
|
|
std::fs::create_dir_all(&p).unwrap();
|
||
|
|
p
|
||
|
|
}
|
||
|
|
|
||
|
|
fn ephemeral_port() -> u16 {
|
||
|
|
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||
|
|
33_000 + ((stamp % 1000) as u16)
|
||
|
|
}
|