The hook installer only ever wrote ~/.claude/settings.json, so a codex session reported no activity and never posted its first prompt — leaving it on the daemon's generated handle with no haiku name. Adds a Target enum (claude-code, codex) over the hook file's location and event set. One script and one marker serve both; codex swaps Notification for PermissionRequest and has no StopFailure, so it never reports Error. Both agents spell the prompt event UserPromptSubmit and put the text in the payload's `prompt`, so autoname needs no change. Codex will not run a hook it has not been told to trust; `maestro hooks install` says so, and the approval stays the operator's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
591 lines
21 KiB
Rust
591 lines
21 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` and `~/.codex/hooks.json`
|
|
//! are never touched.
|
|
|
|
use std::io::{Read, Write};
|
|
use std::process::{Command, Stdio};
|
|
|
|
use super::*;
|
|
|
|
/// How many commands one install writes for a target: its activity events,
|
|
/// plus the prompt event when `haiku-autoname` is on (#131).
|
|
fn installed_events(target: Target) -> usize {
|
|
target.events().count()
|
|
}
|
|
|
|
/// The Claude Code hook file under a temp home. Most installer tests only
|
|
/// need one target, and this is the one they were written against.
|
|
fn settings_path(home: &Path) -> std::path::PathBuf {
|
|
Target::ClaudeCode.settings_path(home)
|
|
}
|
|
|
|
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 CLAUDE_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> = CLAUDE_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(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
|
|
|
let cmds = installed_commands(&settings_path(home));
|
|
assert_eq!(cmds.len(), installed_events(Target::ClaudeCode));
|
|
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(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
|
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
|
|
|
install(Target::ClaudeCode, &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(),
|
|
installed_events(Target::ClaudeCode)
|
|
);
|
|
}
|
|
|
|
/// 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(Target::ClaudeCode, &settings, &new_script).unwrap();
|
|
|
|
let cmds = installed_commands(&settings);
|
|
assert_eq!(
|
|
cmds.len(),
|
|
installed_events(Target::ClaudeCode),
|
|
"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"},
|
|
]}],
|
|
"SessionEnd": [{"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(Target::ClaudeCode, &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("SessionEnd").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(),
|
|
installed_events(Target::ClaudeCode),
|
|
"{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();
|
|
for target in Target::ALL {
|
|
assert!(!is_installed(&target.settings_path(home)));
|
|
}
|
|
for (_, result) in install_for_home(home) {
|
|
result.unwrap();
|
|
}
|
|
for target in Target::ALL {
|
|
assert!(is_installed(&target.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();
|
|
for (_, result) in install_for_home(home) {
|
|
result.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);
|
|
}
|
|
}
|
|
|
|
/// #131: the prompt event is installed alongside the activity ones, pointing
|
|
/// at the same script with the `Prompt` argument.
|
|
#[cfg(feature = "haiku-autoname")]
|
|
#[test]
|
|
fn install_registers_the_prompt_event() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
|
|
|
let root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
|
let group = &root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"];
|
|
let cmd = group.as_str().unwrap();
|
|
assert!(cmd.ends_with(" Prompt"), "{cmd}");
|
|
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
|
}
|
|
|
|
/// With the feature off nothing points at a route the daemon does not serve
|
|
/// (#131).
|
|
#[cfg(not(feature = "haiku-autoname"))]
|
|
#[test]
|
|
fn install_registers_no_prompt_event_without_the_feature() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
install(Target::ClaudeCode, &settings_path(home), &script_path(home)).unwrap();
|
|
|
|
let root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
|
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
|
}
|
|
|
|
/// The script forwards a prompt payload verbatim rather than rebuilding the
|
|
/// JSON in shell, so quotes and newlines in a prompt survive (#131).
|
|
#[test]
|
|
fn the_script_posts_a_prompt_payload_unchanged() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let script = script_path(dir.path());
|
|
write_script(&script).unwrap();
|
|
let bin = dir.path().join("bin");
|
|
std::fs::create_dir_all(&bin).unwrap();
|
|
// A `curl` that records its stdin and argv instead of making a request.
|
|
let record = dir.path().join("curl.log");
|
|
std::fs::write(
|
|
bin.join("curl"),
|
|
format!(
|
|
"#!/bin/sh\ncat > {0}.body\nprintf '%s\\n' \"$@\" > {0}.argv\n",
|
|
record.display()
|
|
),
|
|
)
|
|
.unwrap();
|
|
std::fs::set_permissions(
|
|
bin.join("curl"),
|
|
std::os::unix::fs::PermissionsExt::from_mode(0o755),
|
|
)
|
|
.unwrap();
|
|
|
|
let payload = r#"{"prompt":"fix \"the\" parser\nthen run tests"}"#;
|
|
let mut child = Command::new("sh")
|
|
.arg(&script)
|
|
.arg("Prompt")
|
|
.env(
|
|
"PATH",
|
|
format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
|
|
)
|
|
.env("MAESTRO_SESSION_ID", "abc")
|
|
.env("MAESTRO_URL", "http://127.0.0.1:7070")
|
|
.stdin(Stdio::piped())
|
|
.spawn()
|
|
.unwrap();
|
|
child
|
|
.stdin
|
|
.take()
|
|
.unwrap()
|
|
.write_all(payload.as_bytes())
|
|
.unwrap();
|
|
assert!(child.wait().unwrap().success());
|
|
|
|
let body = std::fs::read_to_string(format!("{}.body", record.display())).unwrap();
|
|
assert_eq!(body, payload);
|
|
let argv = std::fs::read_to_string(format!("{}.argv", record.display())).unwrap();
|
|
assert!(
|
|
argv.contains("http://127.0.0.1:7070/api/sessions/abc/prompt"),
|
|
"{argv}"
|
|
);
|
|
}
|
|
|
|
/// `is_current` answers the upgrade question `is_installed` cannot: an
|
|
/// install left over from an older maestro is present, but not what this
|
|
/// build ships (#131 — the prompt event arrived after #106 was installed).
|
|
#[test]
|
|
fn is_current_rejects_an_install_missing_an_event() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
install_target(Target::ClaudeCode, home).unwrap();
|
|
assert!(is_current(
|
|
Target::ClaudeCode,
|
|
&settings_path(home),
|
|
&script_path(home)
|
|
));
|
|
|
|
// Drop one event, as an older install would have left the file.
|
|
let mut root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(settings_path(home)).unwrap()).unwrap();
|
|
root["hooks"].as_object_mut().unwrap().remove("Stop");
|
|
std::fs::write(
|
|
settings_path(home),
|
|
serde_json::to_string_pretty(&root).unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(is_installed(&settings_path(home)));
|
|
assert!(!is_current(
|
|
Target::ClaudeCode,
|
|
&settings_path(home),
|
|
&script_path(home)
|
|
));
|
|
}
|
|
|
|
/// A settings file that is complete but points at a script from an older
|
|
/// release is not current either — the entries are right, the script is not.
|
|
#[test]
|
|
fn is_current_rejects_a_stale_script() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
install_target(Target::ClaudeCode, home).unwrap();
|
|
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
|
assert!(!is_current(
|
|
Target::ClaudeCode,
|
|
&settings_path(home),
|
|
&script_path(home)
|
|
));
|
|
}
|
|
|
|
/// Nothing installed at all is not current.
|
|
#[test]
|
|
fn is_current_rejects_a_missing_install() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
assert!(!is_current(
|
|
Target::ClaudeCode,
|
|
&settings_path(home),
|
|
&script_path(home)
|
|
));
|
|
}
|
|
|
|
/// `install_target` reports a stale script as a change, even when the
|
|
/// settings entries pointing at it already read correctly.
|
|
#[test]
|
|
fn install_target_reports_a_stale_script_as_a_change() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
|
assert!(!install_target(Target::ClaudeCode, home).unwrap());
|
|
|
|
std::fs::write(script_path(home), "#!/bin/sh\nexit 0\n").unwrap();
|
|
assert!(install_target(Target::ClaudeCode, home).unwrap());
|
|
}
|
|
|
|
/// Codex is hooked in its own file, with its own event set: the two agents
|
|
/// share the script but not the events, so neither file carries the other's
|
|
/// spellings (#131 follow-up — a codex session was never named because
|
|
/// nothing installed its `UserPromptSubmit` hook).
|
|
#[test]
|
|
fn each_target_gets_its_own_file_and_events() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
for (_, result) in install_for_home(home) {
|
|
result.unwrap();
|
|
}
|
|
|
|
let claude = events_in(&Target::ClaudeCode.settings_path(home));
|
|
let codex = events_in(&Target::Codex.settings_path(home));
|
|
assert_eq!(
|
|
Target::Codex.settings_path(home),
|
|
home.join(".codex/hooks.json")
|
|
);
|
|
|
|
// Claude Code's failure signal has no codex equivalent, and codex's
|
|
// approval notice is spelled differently.
|
|
assert!(claude.contains(&"StopFailure".to_string()));
|
|
assert!(claude.contains(&"Notification".to_string()));
|
|
assert!(!codex.contains(&"StopFailure".to_string()));
|
|
assert!(!codex.contains(&"Notification".to_string()));
|
|
assert!(codex.contains(&"PermissionRequest".to_string()));
|
|
|
|
// Both report work and both report a finished turn.
|
|
for event in ["PreToolUse", "PostToolUse", "Stop"] {
|
|
assert!(claude.contains(&event.to_string()), "claude {event}");
|
|
assert!(codex.contains(&event.to_string()), "codex {event}");
|
|
}
|
|
|
|
assert_eq!(
|
|
installed_commands(&Target::Codex.settings_path(home)).len(),
|
|
installed_events(Target::Codex)
|
|
);
|
|
}
|
|
|
|
/// #131: a codex session names itself the same way a Claude Code one does —
|
|
/// same event spelling, same script, same `Prompt` argument.
|
|
#[cfg(feature = "haiku-autoname")]
|
|
#[test]
|
|
fn codex_reports_its_first_prompt_too() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let home = dir.path();
|
|
install_target(Target::Codex, home).unwrap();
|
|
|
|
let root: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(Target::Codex.settings_path(home)).unwrap())
|
|
.unwrap();
|
|
let cmd = root["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_string();
|
|
assert!(cmd.ends_with(" Prompt"), "{cmd}");
|
|
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
|
}
|
|
|
|
/// Every event name a settings file registers a maestro hook under.
|
|
fn events_in(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()
|
|
.flatten()
|
|
.map(|(event, _)| event.clone())
|
|
.collect()
|
|
}
|