use super::*;
use super::doctor_sidecars::{check_search, expected_search_index_id, index_present, probe_health};
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::daemon::search_rpc;
#[test]
fn index_present_matches_each_shape() {
let strings = serde_json::json!(["other", "trusty-mpm"]);
assert!(index_present(&strings, "trusty-mpm"));
let objects = serde_json::json!({"indexes": [{"id": "trusty-mpm"}]});
assert!(index_present(&objects, "trusty-mpm"));
let named = serde_json::json!([{"name": "trusty-mpm"}]);
assert!(index_present(&named, "trusty-mpm"));
let missing = serde_json::json!(["a", "b"]);
assert!(!index_present(&missing, "trusty-mpm"));
}
#[tokio::test]
async fn memory_unreachable_is_fail() {
let socket = std::path::Path::new("/nonexistent/trusty-memory/trusty-memory.sock");
let addr = socket.display().to_string();
let check = probe_health("memory", "trusty-memory", socket, &addr).await;
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn expected_search_index_id_derives_from_project_dir_not_hardcoded() {
let tmp = tempfile::tempdir().unwrap();
let other_repo = tmp.path().join("trusty-tools");
std::fs::create_dir_all(other_repo.join(".git")).unwrap();
let other_id = expected_search_index_id(Some(&other_repo));
assert_eq!(other_id, "trusty-tools");
assert_ne!(
other_id, "trusty-mpm",
"must not fall back to the old hardcoded literal for an unrelated project"
);
let mpm_repo = tmp.path().join("trusty-mpm");
std::fs::create_dir_all(mpm_repo.join(".git")).unwrap();
let mpm_id = expected_search_index_id(Some(&mpm_repo));
assert_eq!(mpm_id, "trusty-mpm");
let nested = other_repo.join("crates").join("trusty-mpm");
std::fs::create_dir_all(&nested).unwrap();
assert_eq!(expected_search_index_id(Some(&nested)), "trusty-tools");
}
struct SearchSocketOverride;
impl SearchSocketOverride {
fn point_at(socket: &std::path::Path) -> Self {
unsafe {
std::env::set_var(search_rpc::TRUSTY_SEARCH_SOCKET_ENV, socket);
}
Self
}
}
impl Drop for SearchSocketOverride {
fn drop(&mut self) {
unsafe {
std::env::remove_var(search_rpc::TRUSTY_SEARCH_SOCKET_ENV);
}
}
}
async fn spawn_search_daemon(indexes: serde_json::Value) -> crate::uds_mock::MockUdsDaemon {
crate::uds_mock::spawn(move |method: &str, _params: serde_json::Value| {
let indexes = indexes.clone();
let method = method.to_string();
Box::pin(async move {
match method.as_str() {
m if m == search_rpc::METHOD_HEALTH => {
Ok(serde_json::json!({"status": "ok", "indexes": 1}))
}
m if m == search_rpc::METHOD_INDEXES_LIST => Ok(indexes),
other => Err(crate::uds_mock::RpcError::new(
-32601,
format!("unknown method {other:?}"),
)),
}
})
})
.await
}
#[tokio::test]
#[serial_test::serial]
async fn search_unreachable_is_fail() {
let _override =
SearchSocketOverride::point_at(std::path::Path::new("/nonexistent/trusty-search/ts.sock"));
let tmp = tempfile::tempdir().unwrap();
let check = check_search(tmp.path(), None).await;
assert_eq!(check.status, CheckStatus::Fail, "{}", check.message);
}
#[tokio::test]
#[serial_test::serial]
async fn search_reports_the_expected_index() {
let project = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join(".git")).unwrap();
let expected = expected_search_index_id(Some(project.path()));
let daemon = spawn_search_daemon(serde_json::json!({"indexes": [expected.clone()]})).await;
let _override = SearchSocketOverride::point_at(daemon.socket());
let home = tempfile::tempdir().unwrap();
let check = check_search(home.path(), Some(project.path())).await;
assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
assert!(check.message.contains(&expected), "{}", check.message);
}
#[tokio::test]
#[serial_test::serial]
async fn search_without_the_expected_index_is_warn() {
let project = tempfile::tempdir().unwrap();
std::fs::create_dir_all(project.path().join(".git")).unwrap();
let daemon = spawn_search_daemon(serde_json::json!({"indexes": ["some-other-index"]})).await;
let _override = SearchSocketOverride::point_at(daemon.socket());
let home = tempfile::tempdir().unwrap();
let check = check_search(home.path(), Some(project.path())).await;
assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
assert!(check.message.contains("missing"), "{}", check.message);
}
#[tokio::test]
async fn agents_check_probes_the_managed_config_tier_not_the_workspace() {
let project = tempfile::tempdir().unwrap();
let report = run_doctor(Some(project.path()), None, &[], None).await;
let agents_check = report
.checks
.iter()
.find(|c| c.name == "agents")
.expect("agents check present");
let workspace_tier = project.path().join(".claude").join("agents");
assert!(
!agents_check
.message
.contains(&workspace_tier.display().to_string()),
"the agents probe must not read the workspace tier any more: {}",
agents_check.message
);
let expected_suffix = std::path::Path::new(".trusty-tools")
.join(crate::core::trusty_tools_config::CRATE_NAME)
.join(crate::core::trusty_tools_config::MANAGED_CLAUDE_CONFIG_SUBDIR)
.join("agents");
assert!(
agents_check
.message
.contains(&expected_suffix.display().to_string()),
"message must name the tm-managed agent deploy tier (…/{}): {}",
expected_suffix.display(),
agents_check.message
);
}
#[tokio::test]
async fn unmanaged_cwd_audits_the_operator_home_tier() {
let project = tempfile::tempdir().unwrap();
let report = run_doctor(Some(project.path()), None, &[], None).await;
let skills = report
.checks
.iter()
.find(|c| c.name == "skills")
.expect("skills check present");
let workspace_tier = project.path().join(".claude").join("skills");
assert!(
!skills
.message
.contains(&workspace_tier.display().to_string()),
"an unregistered directory is not a managed workspace and must not be \
probed as one: {}",
skills.message
);
}
#[tokio::test]
async fn a_registered_workspace_still_gets_the_workspace_layout() {
let project = tempfile::tempdir().unwrap();
let active = vec![project.path().to_path_buf()];
let report = run_doctor(Some(project.path()), None, &active, None).await;
let skills = report
.checks
.iter()
.find(|c| c.name == "skills")
.expect("skills check present");
let workspace_tier = project.path().join(".claude").join("skills");
assert!(
skills
.message
.contains(&workspace_tier.display().to_string()),
"a registered workspace must still be probed at its own tier: {}",
skills.message
);
}
#[test]
fn is_managed_workspace_matches_only_a_registered_path() {
let registered = tempfile::tempdir().unwrap();
let other = tempfile::tempdir().unwrap();
let active = vec![registered.path().to_path_buf()];
assert!(is_managed_workspace(registered.path(), &active));
assert!(!is_managed_workspace(other.path(), &active));
assert!(
!is_managed_workspace(registered.path(), &[]),
"with no live sessions nothing is a managed workspace"
);
}
#[test]
fn is_managed_workspace_sees_through_a_symlinked_path() {
let real = tempfile::tempdir().unwrap();
let link_parent = tempfile::tempdir().unwrap();
let link = link_parent.path().join("workspace-link");
#[cfg(unix)]
std::os::unix::fs::symlink(real.path(), &link).unwrap();
#[cfg(not(unix))]
return;
let active = vec![real.path().to_path_buf()];
assert!(
is_managed_workspace(&link, &active),
"a symlink to a registered workspace is that workspace"
);
}
#[test]
fn an_uncanonicalizable_path_is_not_a_managed_workspace() {
let registered = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let active = vec![registered.path().to_path_buf()];
let absent = scratch.path().join("never-created");
assert!(
!is_managed_workspace(&absent, &active),
"a directory that is not on disk is not a provisioned workspace"
);
#[cfg(unix)]
{
let broken = scratch.path().join("dangling-link");
std::os::unix::fs::symlink(scratch.path().join("no-such-target"), &broken).unwrap();
assert!(
!is_managed_workspace(&broken, &active),
"a symlink to nothing resolves to nothing, not to a workspace"
);
}
let stale = vec![scratch.path().join("reaped-workspace")];
assert!(
!is_managed_workspace(registered.path(), &stale),
"a stale recorded path must not match a live, unrelated directory"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_directory_is_not_a_managed_workspace() {
use std::os::unix::fs::PermissionsExt;
let registered = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let locked = scratch.path().join("locked");
let inner = locked.join("workspace");
std::fs::create_dir_all(&inner).unwrap();
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::canonicalize(&inner).is_ok() {
eprintln!("skipping: cannot deny traversal on this platform/privilege level");
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
return;
}
let managed = is_managed_workspace(&inner, &[registered.path().to_path_buf()]);
let _ = std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755));
assert!(
!managed,
"a directory tm cannot even resolve is not one to deploy into"
);
}
#[test]
fn an_absent_path_still_matches_the_recorded_spelling_of_itself() {
let scratch = tempfile::tempdir().unwrap();
let reaped = scratch.path().join("reaped-workspace");
assert!(
is_managed_workspace(&reaped, std::slice::from_ref(&reaped)),
"an unresolvable path is compared verbatim, so it matches only its own \
recorded spelling — never an unregistered directory"
);
}
#[tokio::test]
async fn run_doctor_produces_sixty_eight_checks() {
let report = run_doctor(None, None, &[], None).await;
let names: Vec<&str> = report.checks.iter().map(|c| c.name.as_str()).collect();
let expected = [
"instructions",
"agents",
"agent_reachability",
"asset_tier",
"asset_duplicates",
"transcript_saving",
"skills",
"skill_source",
"skill_reachability",
"output_style",
"output_style_staleness",
"output_style_legacy_ids",
"deployment",
"skill_staleness",
"skill_unmanaged",
"skill_project_tier",
"legacy_sources",
"legacy_overrides",
"session_profile",
"pm_guard",
"org_accounts",
"instruction_fold",
"tool_output_compression",
"statusline",
"agent_skills",
"agent_skills_prose_hints",
"memory",
"search",
"search_index_pin",
"worktrees",
"worktree_disk",
"disk_usage",
"worktree_registry",
"rust_build_env",
"base_clone",
"gh_account",
"rtk",
"issue_audit_recent",
"oauth_token",
"launchd_secrets",
"credential_reach",
"hooks_contamination",
"hooks_foreign_conflict",
"hooks_build_tree_binary",
"hooks_missing_tm_group",
"tcc_taint",
"scaffold_tracking",
"push_guard",
"maintenance_config",
"maintenance_processes",
"background_sweeps",
"binary_provenance",
"bundled_asset_lag",
"content",
"session_store",
"session_claudes",
"stop_spool",
"stray_mcp_json",
"session_scope",
"tmux_options",
"launchd_process_type",
"tmux_priority",
"tcp_listeners",
"pty_headroom",
"log_drain",
"startup_context",
"auto_memory",
"ancestor_claude_md",
];
assert_eq!(names, expected);
assert_eq!(report.checks.len(), expected.len());
}
#[test]
fn oauth_token_check_warns_when_relocated_with_no_token_or_key() {
let check = build_oauth_token_check(true, false, false);
assert_eq!(check.status, CheckStatus::Warn);
assert!(check.message.contains("tm auth set-token"));
assert!(check.message.contains("claude setup-token"));
}
#[test]
fn oauth_token_check_ok_when_token_stored() {
let check = build_oauth_token_check(true, true, false);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn oauth_token_check_ok_when_env_var_set() {
let check = build_oauth_token_check(true, false, true);
assert_eq!(check.status, CheckStatus::Ok);
}
#[test]
fn oauth_token_check_warns_when_only_api_key_set() {
let check = build_oauth_token_check(true, false, false);
assert_eq!(
check.status,
CheckStatus::Warn,
"an ambient ANTHROPIC_API_KEY must NOT count as managed-session auth"
);
}
#[test]
fn oauth_token_check_ok_when_not_relocated() {
let check = build_oauth_token_check(false, false, false);
assert_eq!(check.status, CheckStatus::Ok);
}
fn counts_for(repos_root: &std::path::Path) -> WorktreeOrphanCounts {
let report = crate::session_manager::worktree_reconcile::reconcile_worktrees(
repos_root,
&[],
&[],
chrono::Utc::now(),
&[],
);
WorktreeOrphanCounts::from_reconcile(&report)
}
#[tokio::test]
async fn worktrees_no_repos_root_is_ok() {
let check = check_worktrees(None, None).await;
assert_eq!(check.status, CheckStatus::Ok);
assert!(check.message.contains("no managed workspace"));
}
#[tokio::test]
async fn worktrees_no_orphans_is_ok() {
let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let _wt = fx.add_worktree("session-abc");
let check = check_worktrees(Some(&fx.repos_root), Some(counts_for(&fx.repos_root))).await;
assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
}
#[tokio::test]
async fn worktrees_unowned_worktree_is_not_an_orphan() {
let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let _a = fx.add_worktree("session-live");
let _b = fx.add_worktree("session-dead");
let counts = counts_for(&fx.repos_root);
assert_eq!(counts.orphaned, 0, "reconcile must report zero orphans");
let check = check_worktrees(Some(&fx.repos_root), Some(counts)).await;
assert_eq!(check.status, CheckStatus::Ok, "{}", check.message);
assert!(check.message.contains("no orphaned worktrees found"));
}
#[tokio::test]
async fn worktrees_with_orphan_is_warn() {
let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let _live = fx.add_worktree("session-live");
let dead = fx.add_worktree("session-dead");
crate::session_manager::worktree_git_fixture::GitWorktreeFixture::stamp_reclaimable_sentinel(
&dead,
);
let check = check_worktrees(Some(&fx.repos_root), Some(counts_for(&fx.repos_root))).await;
assert_eq!(check.status, CheckStatus::Warn, "{}", check.message);
assert!(check.message.contains("1 orphaned"));
assert!(check.message.contains(WORKTREE_REMEDIATION_COMMAND));
assert!(
!check.message.contains("prune --worktrees"),
"the nonexistent flag must not come back: {}",
check.message
);
}
#[tokio::test]
async fn worktrees_without_a_reconciled_inventory_is_unknown() {
let fx = crate::session_manager::worktree_git_fixture::GitWorktreeFixture::new();
let check = check_worktrees(Some(&fx.repos_root), None).await;
assert_eq!(check.status, CheckStatus::Unknown, "{}", check.message);
assert!(check.message.contains("not established"));
}
async fn spawn_health_listener(
body: serde_json::Value,
delay: std::time::Duration,
) -> crate::uds_mock::MockUdsDaemon {
crate::uds_mock::spawn(move |_method: &str, _params: serde_json::Value| {
let body = body.clone();
Box::pin(async move {
tokio::time::sleep(delay).await;
Ok(body)
})
})
.await
}
fn healthy_body() -> serde_json::Value {
serde_json::json!({
"status": "ok",
"daemon_state": "ready",
"worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": true},
})
}
#[tokio::test]
async fn memory_wedged_worker_pool_is_not_ok() {
let body = serde_json::json!({
"status": "ok",
"daemon_state": "ready",
"worker": {"in_flight": 6, "oldest_age_secs": 1800, "wedged": true},
});
let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
let addr = daemon.socket().display().to_string();
let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;
assert_ne!(
check.status,
CheckStatus::Ok,
"a live listener over a wedged worker pool must NOT report healthy: {}",
check.message
);
assert_eq!(check.status, CheckStatus::Fail);
assert!(
check.message.contains("WEDGED"),
"message must name the wedge: {}",
check.message
);
assert!(
check.message.contains("1800"),
"message must carry the observed age: {}",
check.message
);
}
#[tokio::test]
async fn memory_slow_but_serving_daemon_is_ok() {
let daemon =
spawn_health_listener(healthy_body(), std::time::Duration::from_millis(2500)).await;
let addr = daemon.socket().display().to_string();
let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;
assert_eq!(
check.status,
CheckStatus::Ok,
"a serving daemon that is merely slow must not be reported unreachable: {}",
check.message
);
assert!(
!check.message.contains("unreachable"),
"message must not claim unreachable: {}",
check.message
);
}
#[tokio::test]
async fn memory_warming_is_warn_not_fail() {
let body = serde_json::json!({
"status": "ok",
"daemon_state": "warming",
"worker": {
"in_flight": 1,
"oldest_age_secs": 2,
"wedged": false,
"stall_tracking_ok": true,
},
});
let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
let addr = daemon.socket().display().to_string();
let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;
assert_eq!(
check.status,
CheckStatus::Warn,
"post-restart warm-up must be a warning, not a failure: {}",
check.message
);
assert!(
check.message.contains("WARMING"),
"message must name the warm-up: {}",
check.message
);
}
#[tokio::test]
async fn memory_timeout_is_unknown_not_fail() {
let daemon = spawn_health_listener(healthy_body(), std::time::Duration::from_secs(60)).await;
let addr = daemon.socket().display().to_string();
let check = tokio::time::timeout(
std::time::Duration::from_secs(90),
probe_health("memory", "trusty-memory", daemon.socket(), &addr),
)
.await
.expect("probe must stay bounded");
assert_eq!(
check.status,
CheckStatus::Unknown,
"a timed-out probe must be UNKNOWN, not a verdict: {}",
check.message
);
assert!(
check.message.contains("could not be determined"),
"message must say so plainly: {}",
check.message
);
}
#[tokio::test]
async fn health_body_without_worker_block_is_unknown() {
let body = serde_json::json!({"status": "ok", "daemon_state": "ready"});
let daemon = spawn_health_listener(body, std::time::Duration::ZERO).await;
let addr = daemon.socket().display().to_string();
let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;
assert_eq!(
check.status,
CheckStatus::Unknown,
"no worker observation means health is undetermined: {}",
check.message
);
}
#[tokio::test]
async fn memory_without_stall_tracking_is_unknown_not_warn() {
let bodies = [
serde_json::json!({
"status": "degraded",
"detail": "palace lock stall ticker has not run for 412s (interval 30000ms)",
"daemon_state": "ready",
"worker": {"in_flight": 0, "wedged": false, "stall_tracking_ok": false},
}),
serde_json::json!({
"status": "ok",
"daemon_state": "ready",
"worker": {"in_flight": 0, "wedged": false},
}),
];
for body in bodies {
let daemon = spawn_health_listener(body.clone(), std::time::Duration::ZERO).await;
let addr = daemon.socket().display().to_string();
let check = probe_health("memory", "trusty-memory", daemon.socket(), &addr).await;
assert_eq!(
check.status,
CheckStatus::Unknown,
"a daemon not watching its palace locks is undetermined, not a warning: {} ({body})",
check.message
);
assert!(
check.message.contains("stall detector is not reporting"),
"message must name the detector: {}",
check.message
);
}
}
#[test]
fn unknown_never_aggregates_to_ok() {
let report = DoctorReport::from_checks(vec![
DoctorCheck::new("a", CheckStatus::Ok, "fine"),
DoctorCheck::new("b", CheckStatus::Unknown, "no idea"),
]);
assert_eq!(report.overall, CheckStatus::Unknown);
let with_warn = DoctorReport::from_checks(vec![
DoctorCheck::new("a", CheckStatus::Warn, "meh"),
DoctorCheck::new("b", CheckStatus::Unknown, "no idea"),
]);
assert_eq!(with_warn.overall, CheckStatus::Unknown);
let with_fail = DoctorReport::from_checks(vec![
DoctorCheck::new("a", CheckStatus::Unknown, "no idea"),
DoctorCheck::new("b", CheckStatus::Fail, "broken"),
]);
assert_eq!(with_fail.overall, CheckStatus::Fail);
}
#[test]
fn live_workspace_paths_drops_only_claims_a_probe_found_gone() {
let gone = PathBuf::from("/srv/projects/acme");
let still_here = PathBuf::from("/srv/projects/beta");
let claims = LiveClaims::foreign(vec![
WorkspaceClaim::with_liveness("tm-gone", &gone, ClaimLiveness::SessionGone),
WorkspaceClaim::with_liveness("tm-still-here", &still_here, ClaimLiveness::Live),
]);
assert_eq!(live_workspace_paths(&claims), vec![still_here]);
}
#[tokio::test]
async fn doctor_report_includes_rust_build_env_row() {
let project = tempfile::tempdir().expect("temp project");
std::fs::write(project.path().join("package.json"), "{}").expect("a non-Rust marker");
let report = run_doctor(Some(project.path()), None, &[], None).await;
let row = report
.checks
.iter()
.find(|c| c.name == "rust_build_env")
.expect("the rust_build_env row must be registered");
assert_eq!(
row.status,
CheckStatus::Ok,
"a non-Rust project must not be warned about a cargo target directory: {}",
row.message
);
assert!(
row.message.contains("not applicable"),
"the row must say why it did nothing: {}",
row.message
);
}