use super::{start_session, start_session_in_place};
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]
async fn session_start_in_place_writes_stash_and_hard_fails_on_daemon_unreachable() {
let tmp_home = tempfile::TempDir::new().expect("tmp home");
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,
Some(tmp_home.path()),
)
.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"
);
}
#[tokio::test]
async fn session_start_in_place_proceeds_with_a_warning_on_an_unreadable_config() {
use tracing_subscriber::layer::SubscriberExt;
let tmp_home = tempfile::TempDir::new().expect("tmp home");
let target = tempfile::TempDir::new().expect("tmp target dir");
let fw = trusty_mpm::core::paths::FrameworkPaths::under(tmp_home.path());
let state = fw.crate_config_root();
std::fs::create_dir_all(&state).expect("mkdir state root");
std::fs::write(
state.join("config.yaml"),
"tmux:\n alternate_screen: [broken\n",
)
.expect("write config");
let result = start_session_in_place(
&reqwest::Client::new(),
UNREACHABLE_URL,
target.path(),
&fw,
Some(tmp_home.path()),
)
.await;
let err = result.expect_err("the unreachable daemon, not the config, ends this launch");
assert!(!err.to_string().contains("alternate_screen"), "{err}");
assert!(
target
.path()
.join(".trusty-mpm")
.join("last-instructions.md")
.exists(),
"the launch must proceed past the config into prepare_session"
);
crate::test_support::enable_event_capture();
let buffer = trusty_common::log_buffer::LogBuffer::new(64);
let subscriber = tracing_subscriber::registry().with(
trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
);
let line =
tracing::subscriber::with_default(subscriber, || super::inplace_session_line(&state));
let tmux_option = trusty_mpm::core::trusty_tools_config::resolve_tmux_options(
&trusty_mpm::core::trusty_tools_config::TrustyToolsConfig::default(),
)
.alternate_screen;
let want = crate::test_support::renderer_operand(tmux_option);
assert!(
line.contains(want),
"want the tmux option's fallback {want:?} in: {line}"
);
let logged = buffer.tail(64).join("\n");
let config = state.join("config.yaml");
assert!(
logged.contains("alternate_screen") && logged.contains(&config.display().to_string()),
"the warning must name the file: {logged}"
);
}
async fn spawn_capturing_managed_spawn_server_answering(
answer: axum::http::StatusCode,
) -> (
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);
(
answer,
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}"))
}
async fn spawn_capturing_managed_spawn_server() -> (
std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>>,
String,
) {
spawn_capturing_managed_spawn_server_answering(axum::http::StatusCode::OK).await
}
#[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]
async fn session_start_refuses_a_non_git_directory() {
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"
);
}
async fn capture_guided_launch_body(
name_hint: Option<&str>,
isolation: crate::commands::picker_launch_new::LaunchIsolation,
) -> serde_json::Value {
let (captured, url) = spawn_capturing_managed_spawn_server_answering(
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
)
.await;
let client = reqwest::Client::new();
let result = crate::commands::guided_launch::launch_new_session_and_attach(
&client,
&url,
"https://example.invalid/owner/repo.git",
name_hint,
isolation,
)
.await;
assert!(
result.is_err(),
"the harness answers 500 so the call stops at the POST; got {result:?}"
);
captured
.lock()
.expect("captured mutex poisoned")
.clone()
.expect("launch_new_session_and_attach must have POSTed to /api/v1/sessions/managed")
}
#[tokio::test]
async fn launch_new_session_and_attach_sends_the_name_hint() {
let body = capture_guided_launch_body(
Some("auth"),
crate::commands::picker_launch_new::LaunchIsolation::SessionCheckout,
)
.await;
assert_eq!(
body.get("name_hint"),
Some(&serde_json::Value::String("auth".to_string())),
"the named launch must send name_hint as a JSON string: {body}"
);
}
#[tokio::test]
async fn launch_new_session_and_attach_omits_name_hint_when_unnamed() {
let body = capture_guided_launch_body(
None,
crate::commands::picker_launch_new::LaunchIsolation::SessionCheckout,
)
.await;
assert!(
body.get("name_hint").is_none(),
"the unnamed launch must omit name_hint entirely, not send null: {body}"
);
assert_eq!(
body,
serde_json::json!({
"repo_url": "https://example.invalid/owner/repo.git",
"ref": "HEAD",
"task": "",
"force_new": true,
"background": true,
}),
"the unnamed request's wire shape must be unchanged by #4965 or #5773"
);
}
#[tokio::test]
async fn launch_new_session_and_attach_requests_a_worktree_when_asked() {
let body = capture_guided_launch_body(
Some("auth"),
crate::commands::picker_launch_new::LaunchIsolation::OwnWorktree,
)
.await;
assert_eq!(
body.get("worktree"),
Some(&serde_json::Value::Bool(true)),
"an isolation request must send worktree: true: {body}"
);
}
#[test]
fn inplace_session_line_follows_the_configured_renderer() {
for alternate_screen in [true, false] {
let root = crate::test_support::config_root_with_alternate_screen(alternate_screen);
let line = super::inplace_session_line(root.path());
let want = crate::test_support::renderer_operand(alternate_screen);
assert!(line.contains(want), "want {want:?} in: {line}");
}
}