use super::{
classification_clause, diagnostic_lines, print_merged_pr_pass, session_prune_worktrees,
with_heartbeat,
};
#[test]
fn merged_pr_pass_names_a_failed_lookup_beside_the_reclaimable_count() {
let body = serde_json::json!({
"reclaimable": 0,
"reclaimable_bytes": 0,
"reclaimable_measured": 0,
"pr_state_unknown": 4,
"not_inspected": 2,
"lookup_failed": 261,
"lookup_failure": "`gh` exited 4: To get started with GitHub CLI, please run: gh auth login",
});
let clause = classification_clause(&body);
assert!(
clause.contains("261 pull-request lookup(s) FAILED"),
"{clause}"
);
assert!(clause.contains("gh auth login"), "{clause}");
assert!(
clause.contains("4 pull-request state(s) indeterminate"),
"{clause}"
);
assert!(clause.contains("2 not inspected"), "{clause}");
print_merged_pr_pass(Some(&body), true);
print_merged_pr_pass(Some(&body), false);
}
#[test]
fn merged_pr_pass_adds_no_clause_when_everything_classified() {
let body = serde_json::json!({
"reclaimable": 3,
"pr_state_unknown": 0,
"not_inspected": 0,
"lookup_failed": 0,
});
assert_eq!(classification_clause(&body), "");
assert_eq!(classification_clause(&serde_json::json!({})), "");
}
#[test]
fn merged_pr_pass_surfaces_an_agent_owned_skip() {
let body = serde_json::json!({
"removed": [],
"removed_bytes": 0,
"spared_agent_owned": [
"/repo/.claude/worktrees/agent-7f3: owned by dispatched agent agent-7f3 \
— a delegation naming it has not ended",
],
"refused_at_recheck": [],
"removal_failed": [],
});
let lines = diagnostic_lines(&body);
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(lines[0].contains("spared"), "{}", lines[0]);
assert!(lines[0].contains("agent-7f3"), "{}", lines[0]);
print_merged_pr_pass(Some(&body), false);
}
#[test]
fn merged_pr_pass_diagnostics_keep_their_kinds_apart() {
let body = serde_json::json!({
"spared_agent_owned": ["/a: an agent owns it"],
"refused_at_recheck": ["/b: a session claims it now"],
"removal_failed": ["/c: git-locked"],
});
let lines = diagnostic_lines(&body);
assert_eq!(lines.len(), 3, "{lines:?}");
assert!(lines[0].contains("dispatched agent"), "{}", lines[0]);
assert!(lines[1].contains("re-check"), "{}", lines[1]);
assert!(lines[2].contains("removal FAILED"), "{}", lines[2]);
}
#[test]
fn merged_pr_pass_names_the_gate_that_blocked_each_candidate() {
let body = serde_json::json!({
"spared_agent_owned": [],
"refused_at_recheck": [],
"removal_failed": [],
"blocked_reasons": [
"/repo/.claude/worktrees/agent-aaa: blocked at gate 6 (unsaved work): \
holds unsaved work: 0 uncommitted/untracked file(s), 2 unpushed commit(s)",
],
});
let lines = diagnostic_lines(&body);
assert_eq!(lines.len(), 1, "{lines:?}");
assert!(
lines[0].contains("blocked at gate 6 (unsaved work)"),
"{}",
lines[0]
);
assert!(lines[0].contains("agent-aaa"), "{}", lines[0]);
print_merged_pr_pass(Some(&body), true);
}
#[test]
fn merged_pr_pass_diagnostics_tolerate_absent_and_malformed_keys() {
assert!(diagnostic_lines(&serde_json::json!({})).is_empty());
assert!(
diagnostic_lines(&serde_json::json!({ "spared_agent_owned": "not-an-array" })).is_empty()
);
assert!(diagnostic_lines(&serde_json::json!({ "spared_agent_owned": [7, null] })).is_empty());
}
#[test]
fn merged_pr_pass_prints_nothing_for_a_null_body() {
print_merged_pr_pass(None, true);
print_merged_pr_pass(Some(&serde_json::Value::Null), false);
}
#[test]
fn merged_pr_pass_reports_reclaimed_paths_and_bytes() {
let body = serde_json::json!({
"removed": ["/tmp/a", "/tmp/b"],
"removed_bytes": 4096,
"refused_at_recheck": [],
"removal_failed": [],
"reclaimable": 2,
"reclaimable_bytes": 4096,
});
print_merged_pr_pass(Some(&body), false);
print_merged_pr_pass(Some(&body), true);
}
#[test]
fn merged_pr_pass_surfaces_recheck_refusals() {
let body = serde_json::json!({
"removed": [],
"removed_bytes": 0,
"refused_at_recheck": ["/tmp/c: a session claims it now"],
"removal_failed": ["/tmp/d"],
"reclaimable": 1,
});
print_merged_pr_pass(Some(&body), false);
}
#[test]
fn merged_pr_pass_reports_a_failed_pass_rather_than_a_zero_count() {
let body = serde_json::json!({ "error": "task panicked" });
print_merged_pr_pass(Some(&body), false);
}
#[tokio::test]
async fn merged_pr_request_outlives_the_default_client_timeout() {
use std::time::Duration;
const SERVER_DELAY: Duration = Duration::from_millis(1200);
const CLIENT_BOUND: Duration = Duration::from_millis(250);
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_millis(500))
.timeout(CLIENT_BOUND)
.build()
.expect("build a short-bounded test client");
let (url, server) = slow_prune_server(SERVER_DELAY).await;
let overridden = session_prune_worktrees(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
true,
false,
true,
None,
None,
)
.await;
server.await.expect("server task must not panic");
assert!(
overridden.is_ok(),
"`--merged-prs` must carry a per-request timeout longer than the client \
default; got {overridden:?}"
);
let (url, server) = slow_prune_server(SERVER_DELAY).await;
let plain = session_prune_worktrees(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
true,
false,
false,
None,
None,
)
.await;
server.await.expect("server task must not panic");
assert!(
plain.is_err(),
"the orphan-only sweep must keep the short default bound, otherwise this \
test proves nothing about the override"
);
}
#[tokio::test]
async fn prune_worktrees_reports_a_timeout_as_an_error() {
use std::time::Duration;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(250))
.build()
.expect("build a short-bounded test client");
let (url, server) = slow_prune_server(Duration::from_secs(3)).await;
let outcome = session_prune_worktrees(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
false,
false,
false,
None,
None,
)
.await;
server.abort();
let err = outcome.expect_err("a timed-out prune must be an error, never success");
let msg = format!("{err}");
assert!(
msg.contains("timed out waiting for the daemon (#7884)"),
"{msg}"
);
assert!(msg.contains("Nothing is reported as removed"), "{msg}");
}
async fn slow_prune_server(delay: std::time::Duration) -> (String, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind slow prune server");
let url = format!("http://{}", listener.local_addr().expect("local_addr"));
let handle = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept one connection");
let mut seen = Vec::new();
let mut buf = [0u8; 1024];
while !seen.windows(4).any(|w| w == b"\r\n\r\n") {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => return,
Ok(n) => seen.extend_from_slice(&buf[..n]),
}
}
tokio::time::sleep(delay).await;
let body = br#"{"dry_run":true,"paths":[],"skipped_dirty":[],"merged_prs":null}"#;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n",
body.len()
);
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(body).await;
let _ = sock.flush().await;
});
(url, handle)
}
#[tokio::test]
async fn prune_worktrees_sends_the_invoking_session() {
const ID: &str = "6f1c2a5e-0000-4000-8000-000000006806";
let client = reqwest::Client::new();
let (url, server) = capturing_prune_server().await;
let sent = session_prune_worktrees(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
true,
false,
false,
Some(ID.to_string()),
None,
)
.await;
let body = server.await.expect("server task must not panic");
assert!(sent.is_ok(), "{sent:?}");
assert_eq!(
body.get("invoking_session").and_then(|v| v.as_str()),
Some(ID),
"the caller must name itself: {body}"
);
let (url, server) = capturing_prune_server().await;
let sent = session_prune_worktrees(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), url),
true,
false,
false,
None,
None,
)
.await;
let body = server.await.expect("server task must not panic");
assert!(sent.is_ok(), "{sent:?}");
assert!(
body.get("invoking_session")
.is_some_and(serde_json::Value::is_null),
"outside a managed session the caller names none: {body}"
);
}
async fn capturing_prune_server() -> (String, tokio::task::JoinHandle<serde_json::Value>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind capturing prune server");
let url = format!("http://{}", listener.local_addr().expect("local_addr"));
let handle = tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.expect("accept one connection");
let mut seen = Vec::new();
let mut buf = [0u8; 1024];
let head_end = loop {
match seen.windows(4).position(|w| w == b"\r\n\r\n") {
Some(i) => break i + 4,
None => match sock.read(&mut buf).await {
Ok(0) | Err(_) => panic!("connection closed before the request headers ended"),
Ok(n) => seen.extend_from_slice(&buf[..n]),
},
}
};
let head = String::from_utf8_lossy(&seen[..head_end]).to_lowercase();
let len: usize = head
.lines()
.find_map(|l| l.strip_prefix("content-length:"))
.and_then(|v| v.trim().parse().ok())
.expect("the POST must carry a Content-Length");
while seen.len() < head_end + len {
match sock.read(&mut buf).await {
Ok(0) | Err(_) => panic!("connection closed mid-body"),
Ok(n) => seen.extend_from_slice(&buf[..n]),
}
}
let body: serde_json::Value =
serde_json::from_slice(&seen[head_end..head_end + len]).expect("the body is JSON");
let reply = br#"{"dry_run":true,"paths":[],"skipped_dirty":[],"merged_prs":null}"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
Connection: close\r\n\r\n",
reply.len()
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.write_all(reply).await;
let _ = sock.flush().await;
body
});
(url, handle)
}
#[tokio::test]
async fn prune_endpoint_refuses_a_remote_gateway() {
let client = reqwest::Client::new();
let err = super::prune_endpoint(&client, "http://10.20.30.40:7788/api/mpm", || {
panic!("a remote gateway must never resolve the local daemon")
})
.await
.expect_err("a remote gateway must be refused");
assert!(
err.to_string()
.contains("will not run through the console gateway"),
"{err}"
);
}
#[tokio::test]
async fn prune_endpoint_resolves_a_loopback_gateway_to_the_daemon() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind");
let daemon = format!("http://{}", listener.local_addr().expect("addr"));
let router = axum::Router::new().route("/health", axum::routing::get(|| async { "ok" }));
let server = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
});
let client = reqwest::Client::new();
let endpoint =
super::prune_endpoint(&client, "http://localhost:7788/api/mpm", || daemon.clone()).await;
server.abort();
assert_eq!(
endpoint.expect("a loopback gateway resolves"),
format!("{daemon}/api/v1/sessions/managed/prune-worktrees")
);
}
#[tokio::test]
async fn prune_endpoint_errors_when_the_direct_daemon_does_not_answer() {
let client = reqwest::Client::new();
let err = super::prune_endpoint(&client, "http://127.0.0.1:7788/api/mpm", || {
"http://127.0.0.1:1".to_string()
})
.await
.expect_err("an unreachable daemon must stop the prune");
assert!(err.to_string().contains("127.0.0.1:1"), "{err}");
}
#[test]
fn prune_worktrees_url_bypasses_the_gateway_prefix() {
const ENDPOINT: &str = "http://127.0.0.1:7880/api/v1/sessions/managed/prune-worktrees";
for gateway in [
"http://127.0.0.1:7788/api/mpm",
"http://127.0.0.1:7788/api/mpm/",
] {
let url = super::prune_worktrees_url(gateway, "http://127.0.0.1:7880");
assert_eq!(url, ENDPOINT, "gateway base {gateway}");
assert!(!url.contains("/api/mpm"), "{url}");
}
assert_eq!(
super::prune_worktrees_url("http://127.0.0.1:7880/", "unused"),
ENDPOINT
);
}
#[test]
fn merged_pr_pass_tolerates_missing_fields() {
print_merged_pr_pass(Some(&serde_json::json!({})), true);
print_merged_pr_pass(
Some(&serde_json::json!({ "removed": "not-an-array" })),
false,
);
}
#[tokio::test(start_paused = true)]
async fn worktree_8301_the_heartbeat_beats_until_the_reply_arrives() {
let every = std::time::Duration::from_secs(30);
let mut beats = Vec::new();
let reply = with_heartbeat(
async {
tokio::time::sleep(std::time::Duration::from_secs(95)).await;
7
},
every,
|elapsed| beats.push(elapsed.as_secs()),
)
.await;
assert_eq!(reply, 7, "the reply passes through unchanged");
assert_eq!(beats, vec![30, 60, 90]);
let mut none = 0;
let quick = with_heartbeat(async { 1 }, every, |_| none += 1).await;
assert_eq!((quick, none), (1, 0), "a quick reply prints no heartbeat");
}