use super::{start_session, start_session_in_place};
struct HomeGuard(Option<String>);
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0 {
Some(ref p) => unsafe { std::env::set_var("HOME", p) },
None => unsafe { std::env::remove_var("HOME") },
}
}
}
fn set_home(home: &std::path::Path) -> HomeGuard {
let prior = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", home) };
HomeGuard(prior)
}
fn git(dir: &std::path::Path, args: &[&str]) {
let status = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.status()
.unwrap_or_else(|e| panic!("failed to run git {args:?}: {e}"));
assert!(status.success(), "git {args:?} failed in {}", dir.display());
}
const UNREACHABLE_URL: &str = "http://127.0.0.1:1";
#[tokio::test]
async fn session_start_dispatches_managed_new_for_github_repo() {
let tmp = tempfile::TempDir::new().expect("tmp repo");
let repo = tmp.path();
git(repo, &["init", "-q"]);
git(
repo,
&[
"remote",
"add",
"origin",
"https://github.com/example-owner/example-repo.git",
],
);
let client = reqwest::Client::new();
let result = start_session(
&client,
UNREACHABLE_URL,
Some(repo.to_string_lossy().to_string()),
)
.await;
assert!(
result.is_err(),
"expected a hard Err from the managed route on daemon-unreachable, got {result:?}"
);
assert!(
!repo.join(".trusty-mpm").exists(),
"session start must not write .trusty-mpm/ into a protected GitHub repo"
);
assert!(
!repo.join(".claude").join("settings.json").exists(),
"session start must not write .claude/settings.json into a protected GitHub repo"
);
}
#[tokio::test]
#[serial_test::serial]
async fn session_start_in_place_writes_stash_and_hard_fails_on_daemon_unreachable() {
let tmp_home = tempfile::TempDir::new().expect("tmp home");
let _home = set_home(tmp_home.path());
let target = tempfile::TempDir::new().expect("tmp target dir");
let fw = trusty_mpm::core::paths::FrameworkPaths::under(tmp_home.path());
let client = reqwest::Client::new();
let result = start_session_in_place(&client, UNREACHABLE_URL, target.path(), &fw).await;
assert!(
result.is_err(),
"in-place start must hard-fail when the daemon is unreachable (pre-#1916 behavior)"
);
assert!(
target
.path()
.join(".trusty-mpm")
.join("last-instructions.md")
.exists(),
"in-place start must still run prepare_session and stash instructions locally"
);
}
async fn spawn_capturing_managed_spawn_server() -> (
std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>>,
String,
) {
use axum::{Json, Router, routing::post};
let captured: std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>> =
std::sync::Arc::default();
let captured_for_handler = std::sync::Arc::clone(&captured);
let handler = move |Json(body): Json<serde_json::Value>| {
let captured = std::sync::Arc::clone(&captured_for_handler);
async move {
*captured.lock().expect("captured mutex poisoned") = Some(body);
Json(serde_json::json!({
"id": "11111111-1111-1111-1111-111111111111",
"name": "tmpm-test-session",
"state": "Active",
"runtime": "claude-code",
"attach_cmd": "tmux attach -t tmpm-test-session",
}))
}
};
let router = Router::new().route("/api/v1/sessions/managed", post(handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral port");
let addr = listener.local_addr().expect("local_addr");
tokio::spawn(async move {
axum::serve(listener, router).await.ok();
});
(captured, format!("http://{addr}"))
}
#[tokio::test]
async fn session_start_posts_the_same_wire_shape_bare_tm_guided_default_sends() {
let tmp = tempfile::TempDir::new().expect("tmp repo");
let repo = tmp.path().canonicalize().expect("canonicalize tmp repo");
git(&repo, &["init", "-q"]);
git(
&repo,
&[
"remote",
"add",
"origin",
"https://github.com/example-owner/example-repo.git",
],
);
let (captured, url) = spawn_capturing_managed_spawn_server().await;
let client = reqwest::Client::new();
let result = start_session(&client, &url, Some(repo.to_string_lossy().to_string())).await;
assert!(
result.is_ok(),
"expected Ok from a successful spawn, got {result:?}"
);
let body = captured
.lock()
.expect("captured mutex poisoned")
.clone()
.expect("session start must have POSTed to /api/v1/sessions/managed");
let expected = serde_json::json!({
"repo_url": repo.to_string_lossy(),
"ref": "HEAD",
"task": "",
"runtime": "claude-code",
"force_new": true,
});
assert_eq!(
body, expected,
"session start's protected-path request must match bare tm's launch_new_session_and_attach wire shape"
);
}
#[tokio::test]
#[serial_test::serial]
async fn session_start_refuses_a_non_git_directory() {
let tmp_home = tempfile::TempDir::new().expect("tmp home");
let _home = set_home(tmp_home.path());
let tmp = tempfile::TempDir::new().expect("tmp dir");
let plain = tmp.path();
let client = reqwest::Client::new();
let err = start_session(
&client,
UNREACHABLE_URL,
Some(plain.to_string_lossy().to_string()),
)
.await
.expect_err("a non-git directory must be refused");
let msg = err.to_string();
assert!(
msg.contains("not inside a git repository"),
"must say why: {msg}"
);
assert!(msg.contains("git init"), "must point at a remedy: {msg}");
assert!(
!plain.join(".trusty-mpm").exists(),
"no harness state may be scattered into a non-project directory"
);
assert!(
!plain.join("CLAUDE.md").exists(),
"no CLAUDE.md stub may be seeded into a non-project directory"
);
}
#[test]
fn session_start_accepts_a_git_directory() {
let tmp = tempfile::TempDir::new().expect("tmp");
let repo = tmp.path().join("repo");
std::fs::create_dir_all(&repo).unwrap();
git(&repo, &["init", "-q"]);
let sub = repo.join("src");
std::fs::create_dir_all(&sub).unwrap();
super::refuse_outside_a_git_project(&repo).expect("a repo root is accepted");
super::refuse_outside_a_git_project(&sub).expect("a subdirectory is accepted");
let plain = tmp.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
assert!(
super::refuse_outside_a_git_project(&plain).is_err(),
"a non-git directory is refused"
);
}