341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
//! 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<String> {
|
|
serde_json::from_str::<serde_json::Value>(&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);
|
|
}
|
|
}
|