#![cfg(all(feature = "comms", any(unix, windows)))]
use std::sync::Arc;
use std::time::Duration;
use basemind::comms::daemon::Broker;
use basemind::comms::http_frontend;
use basemind::comms::store::CommsStore;
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
async fn http_post(addr: &str, target: &str, body: &[u8], extra_headers: &[(&str, &str)]) -> (u16, String) {
let mut stream = TcpStream::connect(addr).await.expect("connect loopback");
let mut request = format!(
"POST {target} HTTP/1.1\r\nHost: {addr}\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
for (name, value) in extra_headers {
request.push_str(&format!("{name}: {value}\r\n"));
}
request.push_str("\r\n");
stream.write_all(request.as_bytes()).await.expect("write request head");
stream.write_all(body).await.expect("write request body");
stream.flush().await.expect("flush");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("read response");
let text = String::from_utf8_lossy(&raw).into_owned();
let status = text
.split_whitespace()
.nth(1)
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or_else(|| panic!("no status line in response: {text}"));
let body = text.split("\r\n\r\n").nth(1).unwrap_or_default().to_string();
(status, body)
}
async fn http_get(addr: &str, target: &str) -> (u16, String, String) {
let mut stream = TcpStream::connect(addr).await.expect("connect loopback");
let request = format!("GET {target} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await.expect("write request");
stream.flush().await.expect("flush");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("read response");
let text = String::from_utf8_lossy(&raw).into_owned();
let status = text
.split_whitespace()
.nth(1)
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or_else(|| panic!("no status line in response: {text}"));
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
(status, head.to_lowercase(), body.to_string())
}
async fn http_get_with_host(addr: &str, target: &str, host: &str) -> u16 {
let mut stream = TcpStream::connect(addr).await.expect("connect loopback");
let request = format!("GET {target} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).await.expect("write request");
stream.flush().await.expect("flush");
let mut raw = Vec::new();
stream.read_to_end(&mut raw).await.expect("read response");
let text = String::from_utf8_lossy(&raw).into_owned();
text.split_whitespace()
.nth(1)
.and_then(|code| code.parse::<u16>().ok())
.unwrap_or_else(|| panic!("no status line in response: {text}"))
}
fn json_headers() -> Vec<(&'static str, &'static str)> {
vec![
("content-type", "application/json"),
("accept", "application/json, text/event-stream"),
]
}
#[tokio::test]
async fn streamable_http_serves_initialize_and_tools_list() {
basemind::store::init_isolated_cache();
let comms_dir = tempfile::tempdir().expect("comms tempdir");
let repo = tempfile::tempdir().expect("repo tempdir");
std::fs::write(repo.path().join("a.rs"), b"pub fn alpha() {}\n").expect("write source");
let root = std::fs::canonicalize(repo.path()).expect("canonicalize repo root");
unsafe { std::env::set_var(http_frontend::HTTP_ADDR_ENV, "127.0.0.1:0") };
let store = Arc::new(CommsStore::open(comms_dir.path()).expect("open comms store"));
let broker = Arc::new(Broker::new(store));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let broker_for_http = broker.clone();
let comms_path = comms_dir.path().to_path_buf();
let server = tokio::spawn(async move { http_frontend::serve_http(broker_for_http, comms_path, shutdown_rx).await });
let addr = http_frontend::await_http_ready(comms_dir.path(), Duration::from_secs(10))
.await
.expect("streamable-HTTP transport ready");
let root_str = root.to_str().expect("utf-8 repo path");
let target = format!("/mcp?root={root_str}&agent=smoke");
let init = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2026-07-28",
"capabilities": {},
"clientInfo": {"name": "http-smoke", "version": "0"}
}
});
let (status, body) = http_post(&addr, &target, init.to_string().as_bytes(), &json_headers()).await;
assert_eq!(status, 200, "initialize must return 200: {body}");
let parsed: Value = serde_json::from_str(&body).unwrap_or_else(|e| panic!("initialize json ({e}): {body}"));
assert!(
parsed["result"]["serverInfo"]["name"].is_string(),
"initialize result must carry serverInfo: {parsed}"
);
let instructions = parsed["result"]["instructions"].as_str().unwrap_or_default();
assert!(
instructions.contains("basemind") && instructions.contains("indexed context layer"),
"initialize instructions must identify basemind: {parsed}"
);
let list = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}});
let (status, body) = http_post(&addr, &target, list.to_string().as_bytes(), &json_headers()).await;
assert_eq!(status, 200, "tools/list must return 200: {body}");
let parsed: Value = serde_json::from_str(&body).unwrap_or_else(|e| panic!("tools/list json ({e}): {body}"));
let tools = parsed["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools array present: {parsed}"));
assert!(
tools.iter().any(|tool| tool["name"] == "code"),
"tools/list must include the 'code' domain tool: {parsed}"
);
assert!(
tools.len() > 5,
"tools/list must expose the full tool surface, got {}",
tools.len()
);
let (status, _) = http_post(&addr, "/nope", b"{}", &json_headers()).await;
assert_eq!(status, 404, "unknown path must 404");
let (status, _) = http_post(&addr, "/mcp", list.to_string().as_bytes(), &json_headers()).await;
assert_eq!(status, 404, "missing ?root must 404");
shutdown_tx.send(true).ok();
let _ = tokio::time::timeout(Duration::from_secs(5), server).await;
}
#[tokio::test]
async fn ui_route_serves_interactive_html() {
basemind::store::init_isolated_cache();
let comms_dir = tempfile::tempdir().expect("comms tempdir");
let repo = tempfile::tempdir().expect("repo tempdir");
std::fs::write(
repo.path().join("a.rs"),
b"pub fn alpha() { beta(); }\npub fn beta() {}\n",
)
.expect("write source");
let root = std::fs::canonicalize(repo.path()).expect("canonicalize repo root");
unsafe { std::env::set_var(http_frontend::HTTP_ADDR_ENV, "127.0.0.1:0") };
let store = Arc::new(CommsStore::open(comms_dir.path()).expect("open comms store"));
let broker = Arc::new(Broker::new(store));
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let broker_for_http = broker.clone();
let comms_path = comms_dir.path().to_path_buf();
let server = tokio::spawn(async move { http_frontend::serve_http(broker_for_http, comms_path, shutdown_rx).await });
let addr = http_frontend::await_http_ready(comms_dir.path(), Duration::from_secs(10))
.await
.expect("streamable-HTTP transport ready");
let root_str = root.to_str().expect("utf-8 repo path");
let encoded_root = root_str.replace('/', "%2F");
let (status, head, body) = http_get(&addr, &format!("/ui?root={encoded_root}")).await;
assert_eq!(status, 200, "GET /ui must return 200: {body}");
assert!(head.contains("content-type: text/html"), "html content-type: {head}");
assert!(
body.contains("<!doctype html>"),
"serves the self-contained page: {}",
&body[..body.len().min(120)]
);
assert!(body.contains("id=\"c\""), "the canvas element is present");
assert!(body.contains("id=\"search\""), "the live-search input is present");
assert!(
body.contains("application/json"),
"the embedded graph-data island is present"
);
let (status, head, body) = http_get(&addr, "/ui").await;
assert_eq!(status, 400, "missing ?root must 400");
assert!(
head.contains("content-type: text/plain"),
"the 400 is plain text: {head}"
);
assert!(body.contains("root"), "the 400 body names the missing root: {body}");
let (status, _, _) = http_get(&addr, "/ui?root=%2Fno%2Fsuch%2Fpath%2Fbm-xyz").await;
assert_eq!(status, 404, "a root that does not resolve must 404");
let status = http_get_with_host(&addr, &format!("/ui?root={encoded_root}"), "evil.example").await;
assert_eq!(
status, 403,
"a non-loopback Host must be rejected on the loopback listener"
);
let status = http_get_with_host(&addr, &format!("/ui?root={encoded_root}"), "localhost:1234").await;
assert_eq!(status, 200, "a loopback Host (localhost) is still served");
let (status, _, body) = http_get(&addr, &format!("/ui?root={encoded_root}&format=node_link")).await;
assert_eq!(status, 400, "a graph data format is rejected by the route: {body}");
assert!(body.contains("format"), "the 400 body names the bad format: {body}");
let (status, head, _) = http_get(&addr, &format!("/ui?root={encoded_root}&format=svg")).await;
assert_eq!(status, 200, "svg renders");
assert!(head.contains("image/svg+xml"), "svg content-type: {head}");
let (status, _) = http_post(&addr, &format!("/ui?root={encoded_root}"), b"", &[]).await;
assert_eq!(status, 200, "POST /ui serves the same read-only page");
shutdown_tx.send(true).ok();
let _ = tokio::time::timeout(Duration::from_secs(5), server).await;
}