131 lines
4.7 KiB
Rust
131 lines
4.7 KiB
Rust
|
|
//! End-to-end integration tests for aether-recall-mcp.
|
||
|
|
//!
|
||
|
|
//! Spawns the actual binary, talks JSON-RPC over stdio, verifies
|
||
|
|
//! the round-trip and the canonical-form abstraction.
|
||
|
|
|
||
|
|
use serde_json::json;
|
||
|
|
use std::io::{Read, Write};
|
||
|
|
use std::path::PathBuf;
|
||
|
|
use std::process::{Child, Command, Stdio};
|
||
|
|
|
||
|
|
fn binary_path() -> PathBuf {
|
||
|
|
PathBuf::from(env!("CARGO_BIN_EXE_aether-recall-mcp"))
|
||
|
|
}
|
||
|
|
|
||
|
|
struct Mcp {
|
||
|
|
child: Child,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Mcp {
|
||
|
|
fn spawn(data_dir: &PathBuf) -> Self {
|
||
|
|
let mut cmd = Command::new(binary_path());
|
||
|
|
cmd.env("AETHER_RECALL_DIR", data_dir)
|
||
|
|
.stdin(Stdio::piped())
|
||
|
|
.stdout(Stdio::piped())
|
||
|
|
.stderr(Stdio::piped());
|
||
|
|
let child = cmd.spawn().expect("spawn aether-recall-mcp");
|
||
|
|
Self { child }
|
||
|
|
}
|
||
|
|
|
||
|
|
fn send(&mut self, req: serde_json::Value) -> serde_json::Value {
|
||
|
|
let stdin = self.child.stdin.as_mut().expect("stdin");
|
||
|
|
let s = serde_json::to_string(&req).unwrap();
|
||
|
|
writeln!(stdin, "{}", s).unwrap();
|
||
|
|
stdin.flush().unwrap();
|
||
|
|
|
||
|
|
let stdout = self.child.stdout.as_mut().expect("stdout");
|
||
|
|
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 Mcp {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
let _ = self.child.kill();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn full_lifecycle() {
|
||
|
|
let dir = tempdir_in("/tmp");
|
||
|
|
let mut m = Mcp::spawn(&dir);
|
||
|
|
|
||
|
|
// 1. initialize
|
||
|
|
let r = m.send(json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}));
|
||
|
|
assert_eq!(r["result"]["serverInfo"]["name"], "aether-recall");
|
||
|
|
|
||
|
|
// 2. cold lookup → miss
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":2,"method":"tools/call",
|
||
|
|
"params":{"name":"lookup_resolution","arguments":{"failure":"error[E0308]: expected i32, found &str"}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"hit\":false"), "got: {content}");
|
||
|
|
|
||
|
|
// 3. record_outcome
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":3,"method":"tools/call",
|
||
|
|
"params":{"name":"record_outcome","arguments":{"failure":"error[E0308]: expected i32, found &str","resolution":"use .to_string()","worked":true}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"recorded\":true"));
|
||
|
|
|
||
|
|
// 4. warm lookup → hit
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":4,"method":"tools/call",
|
||
|
|
"params":{"name":"lookup_resolution","arguments":{"failure":"error[E0308]: expected i32, found &str"}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"hit\":true"), "got: {content}");
|
||
|
|
assert!(content.contains("use .to_string()"), "got: {content}");
|
||
|
|
|
||
|
|
// 5. path-equivalent surface form → hit (canonical abstraction)
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":5,"method":"tools/call",
|
||
|
|
"params":{"name":"lookup_resolution","arguments":{"failure":"error[E0308]: expected i32, found &str\n --> src/foo.rs:42:5"}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"hit\":true"), "got: {content}");
|
||
|
|
|
||
|
|
// 6. memory_stats: 3 lookups, 2 hits, 1 miss
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":6,"method":"tools/call",
|
||
|
|
"params":{"name":"memory_stats","arguments":{}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"lookups\":3"));
|
||
|
|
assert!(content.contains("\"hits\":2"));
|
||
|
|
assert!(content.contains("\"misses\":1"));
|
||
|
|
assert!(content.contains("\"hit_rate\":0.6"));
|
||
|
|
|
||
|
|
// 7. compact succeeds
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":7,"method":"tools/call",
|
||
|
|
"params":{"name":"compact","arguments":{}}
|
||
|
|
}));
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"compacted\":true"));
|
||
|
|
|
||
|
|
// 8. error: empty failure
|
||
|
|
let r = m.send(json!({
|
||
|
|
"jsonrpc":"2.0","id":8,"method":"tools/call",
|
||
|
|
"params":{"name":"lookup_resolution","arguments":{"failure":""}}
|
||
|
|
}));
|
||
|
|
assert_eq!(r["result"]["isError"], true);
|
||
|
|
let content = r["result"]["content"][0]["text"].as_str().unwrap();
|
||
|
|
assert!(content.contains("\"tool\":\"lookup_resolution\""));
|
||
|
|
}
|
||
|
|
|
||
|
|
fn tempdir_in(parent: &str) -> PathBuf {
|
||
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||
|
|
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||
|
|
let p = PathBuf::from(parent).join(format!("aether-recall-mcp-it-{}", stamp));
|
||
|
|
std::fs::create_dir_all(&p).unwrap();
|
||
|
|
p
|
||
|
|
}
|