//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess) //! and the settings-file installer. Everything happens under a temp `$HOME`; //! the developer's own `~/.claude/settings.json` is never touched. use std::io::{Read, Write}; use std::process::{Command, Stdio}; use super::*; fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output { let mut cmd = Command::new("/bin/sh"); // `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1. let mut argv = vec!["-c", script, HOOK_MARKER]; argv.extend(args.iter().copied()); cmd.args(&argv); // A clean env: only what the test passes, plus PATH so curl can spawn. cmd.env_clear(); cmd.env("PATH", std::env::var("PATH").unwrap_or_default()); for (k, v) in envs { cmd.env(k, v); } let mut child = cmd .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn /bin/sh"); // Feed a Claude-Code-style payload on stdin; the script must drain it. child.stdin.take().unwrap().write_all(payload).ok(); child.wait_with_output().expect("wait") } const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef"; /// Accept exactly one HTTP request on a throwaway listener, answer 204, and /// hand back the raw request text. Runs `run` with the base URL once the /// acceptor thread is in place. fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let handle = std::thread::spawn(move || { let (mut sock, _) = listener.accept().unwrap(); let mut buf = [0u8; 4096]; let n = sock.read(&mut buf).unwrap(); sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") .unwrap(); String::from_utf8_lossy(&buf[..n]).to_string() }); let out = run(format!("http://{addr}")); assert!(out.status.success(), "{:?}", out.stderr); handle.join().unwrap() } fn session_envs(url: &str) -> Vec<(&'static str, String)> { vec![ ("MAESTRO_URL", url.to_string()), ("MAESTRO_SESSION_ID", SESSION.to_string()), ] } fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> { envs.iter().map(|(k, v)| (*k, v.as_str())).collect() } /// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒ /// exit 0, no output, stdin drained. #[test] fn script_exits_silently_without_maestro_env() { for (event, _) in HOOK_EVENTS { let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#); assert!(out.status.success(), "{event:?}: {:?}", out.stderr); assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout); assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr); } } /// With both env vars set the script POSTs the mapped state to the daemon's /// activity endpoint and still exits 0. #[test] fn script_posts_mapped_state_to_the_activity_endpoint() { // The installer bakes the mapped state into the command's argv; the // script relays whatever it is given. let request = capture_request(|url| { sh( HOOK_SCRIPT, &["Active"], &owned_envs(&session_envs(&url)), br#"{"session_id":"x"}"#, ) }); assert!(request.starts_with("POST "), "{request}"); assert!( request.contains(&format!("/api/sessions/{SESSION}/activity")), "{request}" ); assert!(request.contains(r#""state":"Active""#), "{request}"); } /// Every event maps to the state the contract specifies. #[test] fn event_state_mapping_matches_the_contract() { let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect(); assert_eq!(map["PreToolUse"], "Active"); assert_eq!(map["PostToolUse"], "Active"); assert_eq!(map["Notification"], "AwaitingInput"); assert_eq!(map["Stop"], "AwaitingInput"); assert_eq!(map["StopFailure"], "Error"); } /// A `StopFailure` payload names the API error that ended the turn; the /// script reports its `error` spelling as the report's `detail` so a card /// can show why (#114). #[test] fn script_reports_the_error_spelling_as_detail() { let request = capture_request(|url| { sh( HOOK_SCRIPT, &["Error"], &owned_envs(&session_envs(&url)), br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#, ) }); assert!(request.contains(r#""state":"Error""#), "{request}"); assert!(request.contains(r#""detail":"rate_limit""#), "{request}"); } /// An `error` value that is not a plain enum spelling (or a payload without /// one at all) must never produce a broken body — no detail is posted. #[test] fn script_posts_no_detail_for_an_unexpected_error_value() { let request = capture_request(|url| { sh( HOOK_SCRIPT, &["Error"], &owned_envs(&session_envs(&url)), br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#, ) }); assert!(request.contains(r#""state":"Error""#), "{request}"); assert!(!request.contains("detail"), "{request}"); } /// Events whose payload carries no error fields post a bare body — never an /// empty or stale detail. #[test] fn script_posts_no_detail_for_events_without_an_error() { let request = capture_request(|url| { sh( HOOK_SCRIPT, &["AwaitingInput"], &owned_envs(&session_envs(&url)), br#"{"hook_event_name":"Stop","stop_hook_active":false}"#, ) }); assert!(request.contains(r#""state":"AwaitingInput""#), "{request}"); assert!(!request.contains("detail"), "{request}"); } /// A dead or unreachable daemon must never fail — or slow down — a session. #[test] fn script_ignores_daemon_failure() { // Port 1 on localhost is closed in practice; curl fails fast. let out = sh( HOOK_SCRIPT, &["Stop"], &[ ("MAESTRO_URL", "http://127.0.0.1:1"), ("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"), ], br#"{"session_id":"x"}"#, ); assert!(out.status.success()); } // --- installer ------------------------------------------------------------- fn installed_commands(settings: &Path) -> Vec { serde_json::from_str::(&std::fs::read_to_string(settings).unwrap()) .unwrap() .get("hooks") .and_then(|h| h.as_object()) .into_iter() .flat_map(|events| events.values()) .filter_map(|g| g.as_array()) .flatten() .filter_map(|g| g.get("hooks")) .filter_map(|h| h.as_array()) .flatten() .filter_map(|h| h.get("command")) .filter_map(|c| c.as_str()) .map(str::to_string) .collect() } /// Fresh install registers exactly one command per event, all pointing at the /// script with the right state argument. #[test] fn fresh_install_registers_every_event() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); write_script(&script_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap(); let cmds = installed_commands(&settings_path(home)); assert_eq!(cmds.len(), HOOK_EVENTS.len()); let joined = cmds.join("\n"); assert_eq!(joined.matches("Active").count(), 2); assert!(joined.contains("Error")); assert_eq!(joined.matches("AwaitingInput").count(), 2); for cmd in &cmds { assert!(cmd.contains(SCRIPT_FILE_NAME)); } } /// Running install twice changes nothing further — no duplicates. #[test] fn install_is_idempotent() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); install(&settings_path(home), &script_path(home)).unwrap(); let once = std::fs::read_to_string(settings_path(home)).unwrap(); install(&settings_path(home), &script_path(home)).unwrap(); let twice = std::fs::read_to_string(settings_path(home)).unwrap(); assert_eq!(once, twice); assert_eq!( installed_commands(&settings_path(home)).len(), HOOK_EVENTS.len() ); } /// An older maestro entry under the same events is updated in place, not /// duplicated next to the new one. #[test] fn install_updates_an_older_maestro_hook_in_place() { let dir = tempfile::tempdir().unwrap(); let settings = dir.path().join(".claude/settings.json"); std::fs::create_dir_all(settings.parent().unwrap()).unwrap(); let old_script = "/old/location/maestro-claude-hook.sh Active"; std::fs::write( &settings, serde_json::json!({ "model": "sonnet", "hooks": { "Stop": [{"hooks": [{"type": "command", "command": old_script}]}], }, }) .to_string(), ) .unwrap(); let new_script = dir .path() .join(".local/share/maestro") .join(SCRIPT_FILE_NAME); install(&settings, &new_script).unwrap(); let cmds = installed_commands(&settings); assert_eq!( cmds.len(), HOOK_EVENTS.len(), "old entry replaced, not kept: {cmds:?}" ); assert!(!cmds.iter().any(|c| c == old_script)); let root: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); assert_eq!(root["model"], "sonnet", "unrelated fields survive"); } /// A stale maestro entry under an event we no longer use is cleaned up too; /// the user's own hooks are never touched. #[test] fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() { let dir = tempfile::tempdir().unwrap(); let settings = dir.path().join(".claude/settings.json"); std::fs::create_dir_all(settings.parent().unwrap()).unwrap(); std::fs::write( &settings, serde_json::json!({ "hooks": { "PreToolUse": [{"matcher": "Bash", "hooks": [ {"type": "command", "command": "my-own-linter.sh"}, ]}], "UserPromptSubmit": [{"hooks": [ {"type": "command", "command": "/old/maestro-claude-hook.sh Active"}, ]}], }, }) .to_string(), ) .unwrap(); let new_script = dir .path() .join(".local/share/maestro") .join(SCRIPT_FILE_NAME); install(&settings, &new_script).unwrap(); let root: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); // Foreign hook untouched. let pre = root["hooks"]["PreToolUse"].as_array().unwrap(); assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh"); // Stale maestro event gone. assert!(root["hooks"].get("UserPromptSubmit").is_none()); // Our events present, nothing else maestro-related anywhere. let cmds = installed_commands(&settings); assert_eq!( cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(), HOOK_EVENTS.len(), "{cmds:?}" ); } /// `is_installed` flips only once a maestro entry exists. #[test] fn is_installed_tracks_reality() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); assert!(!is_installed(&settings_path(home))); install_for_home(home).unwrap(); assert!(is_installed(&settings_path(home))); } /// The full home-level install leaves the script executable. #[test] fn install_for_home_writes_an_executable_script() { let dir = tempfile::tempdir().unwrap(); let home = dir.path(); install_for_home(home).unwrap(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(script_path(home)) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o111, 0o111); } }