use super::*;
use crate::test_support::STABLE_HOOK_EXE;
fn incident_settings() -> serde_json::Value {
serde_json::json!({
"hooks": {
"PreToolUse": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "/usr/local/bin/tm hook --pm-guard",
"timeout": 5
}]
}],
"SessionStart": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "trusty-memory inbox-check",
"timeout": 60
}]
}]
}
})
}
fn seed_settings(dir: &std::path::Path, value: &serde_json::Value) -> std::path::PathBuf {
let claude = dir.join(".claude");
std::fs::create_dir_all(&claude).expect("create .claude");
let path = claude.join("settings.json");
std::fs::write(
&path,
serde_json::to_string_pretty(value).expect("serialize settings"),
)
.expect("write settings");
path
}
fn read_settings(path: &std::path::Path) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(path).expect("read settings"))
.expect("settings is valid JSON")
}
fn commands_for(val: &serde_json::Value, event: &str) -> Vec<String> {
val["hooks"][event]
.as_array()
.map(|groups| {
groups
.iter()
.filter_map(|g| g.get("hooks").and_then(serde_json::Value::as_array))
.flatten()
.filter_map(|e| e.get("command").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[test]
fn resume_merge_adds_the_sessionstart_group_to_an_existing_file() {
let project = tempfile::tempdir().expect("tempdir");
let path = seed_settings(project.path(), &incident_settings());
ensure_project_hooks(project.path(), Some(std::path::Path::new(STABLE_HOOK_EXE)))
.expect("the merge must succeed against a pinned installed binary");
let after = read_settings(&path);
let commands = commands_for(&after, "SessionStart");
assert!(
commands
.iter()
.any(|c| c == &format!("{STABLE_HOOK_EXE} hook")),
"SessionStart must gain the tm-hook lifecycle entry: {commands:?}"
);
}
#[test]
fn resume_merge_preserves_the_inbox_check_entry() {
let project = tempfile::tempdir().expect("tempdir");
let path = seed_settings(project.path(), &incident_settings());
ensure_project_hooks(project.path(), Some(std::path::Path::new(STABLE_HOOK_EXE)))
.expect("merge");
let commands = commands_for(&read_settings(&path), "SessionStart");
assert!(
commands.iter().any(|c| c == "trusty-memory inbox-check"),
"the project's own SessionStart entry must survive the merge: {commands:?}"
);
}
#[test]
fn resume_merge_leaves_a_complete_file_byte_identical() {
let project = tempfile::tempdir().expect("tempdir");
let path = seed_settings(project.path(), &incident_settings());
let exe = Some(std::path::Path::new(STABLE_HOOK_EXE));
let base = tempfile::tempdir().expect("tempdir");
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace_under(
base.path(),
project.path(),
);
ensure_project_hooks_with(&fw, project.path(), exe).expect("first merge");
let first = std::fs::read(&path).expect("read after first merge");
ensure_project_hooks_with(&fw, project.path(), exe).expect("second merge");
let second = std::fs::read(&path).expect("read after second merge");
assert_eq!(
first, second,
"a settings file that already carries every group must not be rewritten"
);
}
#[test]
fn resume_merge_leaves_an_unparseable_file_untouched() {
let project = tempfile::tempdir().expect("tempdir");
let claude = project.path().join(".claude");
std::fs::create_dir_all(&claude).expect("create .claude");
let path = claude.join("settings.json");
let corrupt = "{\n \"permissions\": { \"allow\": [\"Bash\"] },\n \
\"statusLine\": { \"command\": \"tm statusline\" },\n}\n";
std::fs::write(&path, corrupt).expect("seed settings");
let before = std::fs::read(&path).expect("read before");
ensure_project_hooks(project.path(), Some(std::path::Path::new(STABLE_HOOK_EXE)))
.expect("a refusal is not an error — the launch still proceeds");
assert_eq!(
before,
std::fs::read(&path).expect("read after"),
"an unparseable settings.json must never be rewritten"
);
let backups: Vec<_> = std::fs::read_dir(&claude)
.expect("read .claude")
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".bak"))
.collect();
assert!(
backups.is_empty(),
"a refused merge must not snapshot either: {backups:?}"
);
}
#[test]
fn resume_merge_still_creates_a_missing_file() {
let project = tempfile::tempdir().expect("tempdir");
ensure_project_hooks(project.path(), Some(std::path::Path::new(STABLE_HOOK_EXE)))
.expect("merge");
let path = project.path().join(".claude").join("settings.json");
let commands = commands_for(&read_settings(&path), "SessionStart");
assert!(
commands
.iter()
.any(|c| c == &format!("{STABLE_HOOK_EXE} hook")),
"a project with no settings file must still be provisioned: {commands:?}"
);
}
#[test]
fn missing_events_names_a_sessionstart_gap() {
let gaps = missing_lifecycle_hook_events(&incident_settings());
assert!(
gaps.iter().any(|e| e == "SessionStart"),
"SessionStart must be reported as missing: {gaps:?}"
);
assert!(
!gaps.is_empty() && gaps.iter().all(|e| e != "UserPromptSubmit"),
"only the six lifecycle events are in scope: {gaps:?}"
);
}
#[test]
fn missing_events_is_empty_for_a_complete_file() {
let project = tempfile::tempdir().expect("tempdir");
let path = seed_settings(project.path(), &incident_settings());
ensure_project_hooks(project.path(), Some(std::path::Path::new(STABLE_HOOK_EXE)))
.expect("merge");
let gaps = missing_lifecycle_hook_events(&read_settings(&path));
assert!(
gaps.is_empty(),
"the merged file must have no gaps: {gaps:?}"
);
}
#[test]
fn missing_events_is_empty_for_a_file_tm_never_provisioned() {
let foreign = serde_json::json!({
"hooks": {
"SessionStart": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "claude-mpm hook",
"timeout": 5
}]
}]
}
});
let gaps = missing_lifecycle_hook_events(&foreign);
assert!(
gaps.is_empty(),
"a project tm never provisioned owes no tm hook group: {gaps:?}"
);
}
#[test]
fn resume_merge_registers_the_prompt_feedback_capture_when_the_flag_is_on() {
let project = tempfile::tempdir().expect("tempdir");
let path = seed_settings(project.path(), &incident_settings());
std::fs::write(
project
.path()
.join(crate::core::project_config::PROJECT_CONFIG_FILE),
"prompt_self_improvement = true\n",
)
.expect("write project config");
let exe = Some(std::path::Path::new(STABLE_HOOK_EXE));
let base = tempfile::tempdir().expect("tempdir");
let fw = crate::core::paths::FrameworkPaths::for_managed_workspace_under(
base.path(),
project.path(),
);
for pass in ["first", "second"] {
ensure_project_hooks_with(&fw, project.path(), exe)
.unwrap_or_else(|e| panic!("{pass} merge: {e}"));
for event in ["Stop", "SubagentStop"] {
let commands = commands_for(&read_settings(&path), event);
assert!(
commands
.iter()
.any(|c| c == &format!("{STABLE_HOOK_EXE} hook --prompt-feedback")),
"{pass} merge must leave the {event} capture registered: {commands:?}"
);
}
}
}