use std::path::Path;
use lingshu_gateway::hooks::{HookContext, HookRegistry, HookResult, LoadedHookInfo};
fn python3_available() -> bool {
which::which("python3").is_ok()
}
macro_rules! require_python3 {
() => {
if !python3_available() {
eprintln!("SKIP: python3 not found on PATH");
return;
}
};
}
fn write_py_hook(base: &Path, name: &str, yaml: &str, script: &str) {
let dir = base.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("HOOK.yaml"), yaml).unwrap();
std::fs::write(dir.join("handler.py"), script).unwrap();
}
fn registry_from(base: &Path) -> HookRegistry {
let mut reg = HookRegistry::new();
reg.discover_and_load_from(base);
reg
}
#[test]
fn discovery_empty_dir_loads_nothing() {
let dir = tempfile::tempdir().unwrap();
let reg = registry_from(dir.path());
assert_eq!(reg.hook_count(), 0);
assert!(reg.loaded_hooks().is_empty());
}
#[test]
fn discovery_skips_dir_with_no_handler() {
let dir = tempfile::tempdir().unwrap();
let hook_dir = dir.path().join("no-handler");
std::fs::create_dir_all(&hook_dir).unwrap();
std::fs::write(
hook_dir.join("HOOK.yaml"),
b"name: no-handler\nevents:\n - \"*\"\n",
)
.unwrap();
let reg = registry_from(dir.path());
assert_eq!(
reg.hook_count(),
0,
"hook without handler should be skipped"
);
}
#[test]
fn discovery_skips_invalid_yaml() {
let dir = tempfile::tempdir().unwrap();
let hook_dir = dir.path().join("bad-yaml");
std::fs::create_dir_all(&hook_dir).unwrap();
std::fs::write(hook_dir.join("HOOK.yaml"), b": : :::not valid yaml").unwrap();
std::fs::write(hook_dir.join("handler.py"), b"pass").unwrap();
let reg = registry_from(dir.path());
assert_eq!(
reg.hook_count(),
0,
"hook with invalid HOOK.yaml should be skipped"
);
}
#[test]
fn discovery_skips_disabled_hook() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"disabled",
"name: disabled\nevents:\n - \"*\"\nenabled: false\n",
"pass",
);
let reg = registry_from(dir.path());
assert_eq!(reg.hook_count(), 0, "disabled hook should not be loaded");
}
#[test]
fn discovery_loads_valid_hook_metadata() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"my-hook",
"name: my-hook\ndescription: A test hook\nevents:\n - \"session:start\"\npriority: 10\n",
"pass",
);
let reg = registry_from(dir.path());
assert_eq!(reg.hook_count(), 1);
let infos: &[LoadedHookInfo] = reg.loaded_hooks();
assert_eq!(infos.len(), 1);
let info = &infos[0];
assert_eq!(info.name, "my-hook");
assert_eq!(info.description, "A test hook");
assert_eq!(info.events, vec!["session:start"]);
assert_eq!(info.priority, 10);
assert_eq!(info.language, "python");
}
#[tokio::test]
async fn discovery_priority_ordering_lower_fires_first() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"second-hook",
"name: second-hook\nevents:\n - \"*\"\npriority: 100\n",
"pass",
);
write_py_hook(
dir.path(),
"first-hook",
"name: first-hook\nevents:\n - \"*\"\npriority: 1\n",
"pass",
);
let reg = registry_from(dir.path());
assert_eq!(reg.hook_count(), 2);
assert_eq!(reg.loaded_hooks().len(), 2);
}
#[tokio::test]
async fn emit_noop_hooks_complete_without_error() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"echo",
"name: echo\nevents:\n - \"session:*\"\n",
"import sys\nsys.stdout.write('{}')",
);
let reg = registry_from(dir.path());
reg.emit(
"session:start",
&HookContext::new("session:start").with_session("s-1"),
)
.await;
}
#[tokio::test]
async fn emit_cancellable_python_script_cancel() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"blocker",
"name: blocker\nevents:\n - \"tool:pre\"\n",
r#"import sys, json
data = json.load(sys.stdin)
sys.stdout.write(json.dumps({"cancel": True, "reason": "blocked-by-test"}))
"#,
);
let reg = registry_from(dir.path());
let result = reg
.emit_cancellable(
"tool:pre",
&HookContext::new("tool:pre").with_str("tool_name", "bash"),
)
.await;
assert!(result.is_cancel(), "expected Cancel result: {result:?}");
if let HookResult::Cancel { reason } = result {
assert_eq!(reason, "blocked-by-test");
}
}
#[tokio::test]
async fn global_wildcard_hook_fires_for_all_events() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"catchall",
"name: catchall\nevents:\n - \"*\"\n",
"import sys\nsys.stdout.write('{}')",
);
let reg = registry_from(dir.path());
for event in &[
"session:start",
"tool:pre",
"llm:post",
"gateway:startup",
"command:new",
] {
reg.emit(event, &HookContext::new(*event)).await;
}
}
#[tokio::test]
async fn hook_not_subscribed_to_event_not_invoked() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"llm-only",
"name: llm-only\nevents:\n - \"llm:*\"\n",
"import sys\nsys.stdout.write('{}')",
);
let reg = registry_from(dir.path());
reg.emit("session:start", &HookContext::new("session:start"))
.await;
}
#[tokio::test]
async fn script_hook_timeout_returns_continue() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"sleeper",
"name: sleeper\nevents:\n - \"*\"\ntimeout_secs: 1\n",
"import time\ntime.sleep(60)\n",
);
let reg = registry_from(dir.path());
let result = reg
.emit_cancellable("session:start", &HookContext::new("session:start"))
.await;
assert_eq!(result, HookResult::Continue);
}
#[tokio::test]
async fn env_vars_injected_into_script() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"env-check",
"name: env-check\nevents:\n - \"*\"\nenv:\n HOOK_SECRET: my_secret_42\n",
r#"import os, sys, json
secret = os.environ.get("HOOK_SECRET", "MISSING")
# Signal via cancel reason so the test can verify the value.
sys.stdout.write(json.dumps({"cancel": True, "reason": secret}))
"#,
);
let reg = registry_from(dir.path());
let result = reg
.emit_cancellable("tool:pre", &HookContext::new("tool:pre"))
.await;
if let HookResult::Cancel { reason } = result {
assert_eq!(reason, "my_secret_42", "env var not injected correctly");
} else {
panic!("expected Cancel with env var value, got Continue");
}
}
#[tokio::test]
async fn context_json_delivered_to_script_and_parseable() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"ctx-echo",
"name: ctx-echo\nevents:\n - \"tool:pre\"\n",
r#"import json, sys
data = json.load(sys.stdin)
# Verify expected fields are present.
assert data["event"] == "tool:pre", f"bad event: {data}"
assert data.get("session_id") == "s-xyz", f"missing session_id: {data}"
assert data.get("tool_name") == "bash", f"missing tool_name: {data}"
sys.stdout.write("{}")
"#,
);
let reg = registry_from(dir.path());
let ctx = HookContext::new("tool:pre")
.with_session("s-xyz")
.with_str("tool_name", "bash");
let result = reg.emit_cancellable("tool:pre", &ctx).await;
assert_eq!(result, HookResult::Continue);
}
#[test]
fn hook_context_to_json_contains_all_fields() {
let ctx = HookContext::new("agent:start")
.with_session("s-1")
.with_user("u-2")
.with_platform("telegram")
.with_str("model", "claude-3-5-sonnet")
.with_value("tokens", serde_json::json!({"prompt": 200}));
let json = ctx.to_json().expect("serialize");
let v: serde_json::Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["event"], "agent:start");
assert_eq!(v["session_id"], "s-1");
assert_eq!(v["user_id"], "u-2");
assert_eq!(v["platform"], "telegram");
assert_eq!(v["model"], "claude-3-5-sonnet");
assert_eq!(v["tokens"]["prompt"], 200);
}
#[tokio::test]
async fn prefix_wildcard_fires_for_all_matching_events() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"tool-watcher",
"name: tool-watcher\nevents:\n - \"tool:*\"\n",
"import sys\nsys.stdout.write('{}')",
);
let reg = registry_from(dir.path());
reg.emit("tool:pre", &HookContext::new("tool:pre")).await;
reg.emit("tool:post", &HookContext::new("tool:post")).await;
reg.emit("session:start", &HookContext::new("session:start"))
.await;
}
#[test]
fn discovery_loads_multiple_hooks() {
require_python3!();
let dir = tempfile::tempdir().unwrap();
write_py_hook(
dir.path(),
"alpha",
"name: alpha\nevents:\n - \"*\"\n",
"pass",
);
write_py_hook(
dir.path(),
"beta",
"name: beta\nevents:\n - \"session:*\"\n",
"pass",
);
write_py_hook(
dir.path(),
"gamma",
"name: gamma\nevents:\n - \"tool:*\"\n",
"pass",
);
let reg = registry_from(dir.path());
assert_eq!(reg.hook_count(), 3);
assert_eq!(reg.loaded_hooks().len(), 3);
}