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(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), 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(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), 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(
&trusty_mpm::client::DaemonClient::with_client(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 spec = tracing::subscriber::with_default(subscriber, || {
super::inplace_session_spec(target.path(), &state, &probe_prompt("/probe/p.txt"))
});
let line = crate::test_support::spec_text(&spec);
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(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), 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(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), 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(
&trusty_mpm::client::DaemonClient::with_client(client.clone(), 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_spec_follows_the_configured_renderer() {
for alternate_screen in [true, false] {
let root = crate::test_support::config_root_with_alternate_screen(alternate_screen);
let spec = super::inplace_session_spec(
std::path::Path::new("/w"),
root.path(),
&probe_prompt("/probe/p.txt"),
);
let line = crate::test_support::spec_text(&spec);
let want = crate::test_support::renderer_operand(alternate_screen);
assert!(line.contains(want), "want {want:?} in: {line}");
}
}
fn probe_prompt(file: &str) -> super::InplacePrompt {
super::InplacePrompt {
file: std::path::PathBuf::from(file),
stamp: trusty_mpm::core::session_profile::launch_env(
trusty_mpm::core::session_profile::SessionProfile::Pm,
),
}
}
#[test]
fn inplace_session_spec_carries_the_prompt_file() {
let root = tempfile::TempDir::new().expect("tmp config root");
let spec = super::inplace_session_spec(
std::path::Path::new("/w"),
root.path(),
&probe_prompt("/probe/pm-prompt.txt"),
);
assert_eq!(
spec.args,
[
"--append-system-prompt-file",
"/probe/pm-prompt.txt",
"--dangerously-skip-permissions"
],
"the in-place launch must carry the PM prompt file"
);
}
#[test]
fn inplace_prompt_file_returns_the_written_prompt_file() {
let project = tempfile::TempDir::new().expect("tmp project");
let dir = tempfile::TempDir::new().expect("tmp prompt dir");
let prompt = super::inplace_prompt_file(project.path(), dir.path())
.expect("a written prompt file is returned");
let file = prompt.file;
assert!(file.starts_with(dir.path()), "{}", file.display());
let written = std::fs::read_to_string(&file).expect("read the prompt file");
assert!(
!written.trim().is_empty(),
"the composed PM prompt must reach the file"
);
}
#[test]
fn inplace_prompt_file_refuses_when_the_prompt_file_cannot_be_written() {
let project = tempfile::TempDir::new().expect("tmp project");
let tmp = tempfile::TempDir::new().expect("tmp dir");
let not_a_dir = tmp.path().join("not-a-dir");
std::fs::write(¬_a_dir, "").expect("plant a file where the prompt dir goes");
let err = super::inplace_prompt_file(project.path(), ¬_a_dir)
.expect_err("a failed prompt write must refuse the launch")
.to_string();
assert!(
err.contains(&*not_a_dir.to_string_lossy())
&& err.contains("os error")
&& err.contains("refusing to launch"),
"the error must name the file, the cause and the refusal: {err}"
);
}
#[test]
fn inplace_session_spec_carries_the_profile_stamp() {
let root = tempfile::TempDir::new().expect("tmp config root");
let mut prompt = probe_prompt("/probe/p.txt");
prompt.stamp = trusty_mpm::core::session_profile::launch_env(
trusty_mpm::core::session_profile::SessionProfile::Supervisor,
);
let spec = super::inplace_session_spec(std::path::Path::new("/w"), root.path(), &prompt);
assert!(
spec.env_set.contains(&prompt.stamp),
"the in-place spec must assign {:?}: {:?}",
prompt.stamp,
spec.env_set
);
}
#[test]
fn inplace_prompt_file_stamps_the_profile_it_composed_for() {
let project = tempfile::TempDir::new().expect("tmp project");
let dir = tempfile::TempDir::new().expect("tmp prompt dir");
let prompt = super::inplace_prompt_file(project.path(), dir.path()).expect("written");
let cli = trusty_mpm::core::session_launch::cli_launch(
crate::commands::install::test_content_ref(),
project.path(),
None,
);
assert_eq!(
prompt.stamp,
trusty_mpm::core::session_profile::launch_env(cli.profile)
);
}
#[test]
fn manual_launch_hint_names_the_prompt_file() {
let hint = super::manual_launch_hint("tm-x", "/w", std::path::Path::new("/t/p.txt"));
assert!(
hint.contains("`claude --append-system-prompt-file '/t/p.txt'`") && hint.contains("/w"),
"{hint}"
);
}