fix(tests): cover hook event dispatch paths

This commit is contained in:
Zhang Yonglun
2026-05-09 23:38:26 +08:00
committed by Hunter Bown
parent 0428f08625
commit fd5a0aaec5
+126
View File
@@ -168,3 +168,129 @@ impl HookDispatcher {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn hook_event_serializes_with_snake_case_type_and_payload() {
let event = HookEvent::ToolLifecycle {
response_id: "resp-1".to_string(),
tool_name: "shell".to_string(),
phase: "end".to_string(),
payload: json!({ "exit_code": 0 }),
};
let encoded = event.to_json();
assert_eq!(encoded["type"], "tool_lifecycle");
assert_eq!(encoded["response_id"], "resp-1");
assert_eq!(encoded["tool_name"], "shell");
assert_eq!(encoded["phase"], "end");
assert_eq!(encoded["payload"]["exit_code"], 0);
}
#[tokio::test]
async fn jsonl_sink_creates_parent_dir_and_appends_events() {
let root = unique_temp_dir("jsonl_sink");
let path = root.join("nested").join("hooks.jsonl");
let sink = JsonlHookSink::new(path.clone());
sink.emit(&HookEvent::ResponseStart {
response_id: "resp-1".to_string(),
})
.await
.unwrap();
sink.emit(&HookEvent::ResponseEnd {
response_id: "resp-1".to_string(),
})
.await
.unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let lines = raw.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
let first: Value = serde_json::from_str(lines[0]).unwrap();
let second: Value = serde_json::from_str(lines[1]).unwrap();
assert!(first["at"].as_str().is_some());
assert_eq!(first["event"]["type"], "response_start");
assert_eq!(first["event"]["response_id"], "resp-1");
assert_eq!(second["event"]["type"], "response_end");
assert_eq!(second["event"]["response_id"], "resp-1");
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn dispatcher_continues_after_sink_error() {
let mut dispatcher = HookDispatcher::default();
let first = Arc::new(RecordingSink::default());
let second = Arc::new(RecordingSink::default());
dispatcher.add_sink(first.clone());
dispatcher.add_sink(Arc::new(FailingSink));
dispatcher.add_sink(second.clone());
dispatcher
.emit(HookEvent::ApprovalLifecycle {
approval_id: "approval-1".to_string(),
phase: "requested".to_string(),
reason: Some("needs review".to_string()),
})
.await;
assert_eq!(
first.events(),
vec![json!({
"type": "approval_lifecycle",
"approval_id": "approval-1",
"phase": "requested",
"reason": "needs review",
})]
);
assert_eq!(second.events(), first.events());
}
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<Value>>,
}
impl RecordingSink {
fn events(&self) -> Vec<Value> {
self.events.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl HookSink for RecordingSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
self.events.lock().unwrap().push(event.to_json());
Ok(())
}
}
struct FailingSink;
#[async_trait::async_trait]
impl HookSink for FailingSink {
async fn emit(&self, _event: &HookEvent) -> Result<()> {
anyhow::bail!("sink failed")
}
}
fn unique_temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"deepseek-hooks-{label}-{}-{nanos}",
std::process::id()
))
}
}