From fd5a0aaec51d2c0f692a120d6002ab846564dbf2 Mon Sep 17 00:00:00 2001 From: Zhang Yonglun Date: Sat, 9 May 2026 23:38:26 +0800 Subject: [PATCH] fix(tests): cover hook event dispatch paths --- crates/hooks/src/lib.rs | 126 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/crates/hooks/src/lib.rs b/crates/hooks/src/lib.rs index 2abe5fb3..d2af4ec4 100644 --- a/crates/hooks/src/lib.rs +++ b/crates/hooks/src/lib.rs @@ -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::>(); + 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>, + } + + impl RecordingSink { + fn events(&self) -> Vec { + 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() + )) + } +}