#![cfg(unix)]
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::collections::HashMap;
use std::path::PathBuf;
use capo_agent::extensions::{
dispatcher::{
dispatch_before_user_message, dispatch_command, dispatch_session_before_switch,
BeforeUserMessageOutcome, CommandOutcome, HookOutcome,
},
manifest::{ExtensionEntry, ExtensionManifestFile},
ExtensionRegistry,
};
fn fixture_path(name: &str) -> String {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests");
p.push("fixtures");
p.push("extensions");
p.push(name);
p.to_string_lossy().into_owned()
}
fn entry(name: &str, fixture: &str, hooks: Vec<&str>, timeout_ms: Option<u64>) -> ExtensionEntry {
ExtensionEntry {
name: name.into(),
command: fixture_path(fixture),
args: Vec::new(),
env: HashMap::new(),
timeout_ms,
hooks: hooks.into_iter().map(String::from).collect(),
commands: Vec::new(),
}
}
fn registry_with(entries: Vec<ExtensionEntry>) -> ExtensionRegistry {
let mut diags = Vec::new();
ExtensionRegistry::build(
ExtensionManifestFile {
extensions: entries,
},
&mut diags,
)
}
#[tokio::test]
async fn always_continue_yields_continue_outcome() {
let reg = registry_with(vec![entry(
"alwaysok",
"always_continue.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn cancel_short_circuits_chain() {
let reg = registry_with(vec![
entry(
"first_cancels",
"cancel_with_reason.sh",
vec!["session_before_switch"],
None,
),
entry(
"second_should_not_run",
"always_continue.sh",
vec!["session_before_switch"],
None,
),
]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
match outcome {
HookOutcome::Cancelled {
extension_name,
reason,
} => {
assert_eq!(extension_name, "first_cancels");
assert_eq!(reason.as_deref(), Some("fixture cancel reason"));
}
other => panic!("expected Cancelled; got {other:?}"),
}
}
#[tokio::test]
async fn timeout_falls_back_to_continue() {
let reg = registry_with(vec![entry(
"slow",
"slow.sh",
vec!["session_before_switch"],
Some(200),
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn non_zero_exit_falls_back_to_continue() {
let reg = registry_with(vec![entry(
"exit_one",
"exit_one.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn non_zero_exit_ignores_cancel_stdout() {
let reg = registry_with(vec![entry(
"cancel_exit_one",
"cancel_exit_one.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn stdout_cancel_from_hanging_extension_falls_back_to_continue() {
let reg = registry_with(vec![entry(
"cancel_then_sleep",
"cancel_then_sleep.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn extension_env_does_not_override_parent_path() {
let ext = ExtensionEntry {
env: HashMap::from([("PATH".into(), "/definitely/not/a/real/path".into())]),
..entry(
"parent_path_wins",
"cancel_with_reason.sh",
vec!["session_before_switch"],
None,
)
};
let reg = registry_with(vec![ext]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
match outcome {
HookOutcome::Cancelled { extension_name, .. } => {
assert_eq!(extension_name, "parent_path_wins");
}
other => panic!("expected parent PATH to let fixture run; got {other:?}"),
}
}
#[tokio::test]
async fn garbage_stdout_falls_back_to_continue() {
let reg = registry_with(vec![entry(
"garbage",
"garbage.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn missing_binary_falls_back_to_continue() {
let reg = registry_with(vec![entry(
"ghost",
"does-not-exist-anywhere-12345.sh",
vec!["session_before_switch"],
None,
)]);
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn no_extensions_subscribed_yields_continue() {
let reg = registry_with(Vec::new());
let outcome = dispatch_session_before_switch(®, "new", None).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn before_user_message_no_subscribers_proceeds_unchanged() {
let reg = registry_with(Vec::new());
let outcome = dispatch_before_user_message(®, "hi".into(), Vec::new()).await;
match outcome {
BeforeUserMessageOutcome::Proceed { text, attachments } => {
assert_eq!(text, "hi");
assert!(attachments.is_empty());
}
other => panic!("expected Proceed; got {other:?}"),
}
}
#[tokio::test]
async fn before_user_message_transform_text_chains_through_two_extensions() {
let reg = registry_with(vec![
entry(
"first",
"transform_text.sh",
vec!["before_user_message"],
None,
),
entry(
"second",
"transform_text.sh",
vec!["before_user_message"],
None,
),
]);
let outcome = dispatch_before_user_message(®, "hi".into(), Vec::new()).await;
match outcome {
BeforeUserMessageOutcome::Proceed { text, .. } => {
assert_eq!(text, "[ext] [ext] hi");
}
other => panic!("expected Proceed; got {other:?}"),
}
}
#[tokio::test]
async fn before_user_message_cancel_short_circuits() {
let reg = registry_with(vec![
entry(
"cancels",
"cancel_with_reason.sh",
vec!["before_user_message"],
None,
),
entry(
"should_not_run",
"transform_text.sh",
vec!["before_user_message"],
None,
),
]);
let outcome = dispatch_before_user_message(®, "hi".into(), Vec::new()).await;
match outcome {
BeforeUserMessageOutcome::Cancelled {
extension_name,
reason,
} => {
assert_eq!(extension_name, "cancels");
assert_eq!(reason.as_deref(), Some("fixture cancel reason"));
}
other => panic!("expected Cancelled; got {other:?}"),
}
}
#[tokio::test]
async fn command_unknown_command_yields_unknown_outcome() {
let reg = registry_with(Vec::new());
let outcome = dispatch_command(®, "todo", "args here").await;
assert_eq!(outcome, CommandOutcome::Unknown);
}
#[tokio::test]
async fn command_reply_action_returns_reply_outcome() {
let mut e = entry("todo_ext", "reply_command.sh", Vec::new(), None);
e.commands = vec!["todo".into()];
let reg = registry_with(vec![e]);
let outcome = dispatch_command(®, "todo", "").await;
match outcome {
CommandOutcome::Reply { text } => assert_eq!(text, "reply from fixture"),
other => panic!("expected Reply; got {other:?}"),
}
}
#[tokio::test]
async fn command_send_action_returns_send_outcome() {
let mut e = entry("todo_ext", "send_command.sh", Vec::new(), None);
e.commands = vec!["todo".into()];
let reg = registry_with(vec![e]);
let outcome = dispatch_command(®, "todo", "").await;
match outcome {
CommandOutcome::Send { text, attachments } => {
assert_eq!(text, "sent from fixture");
assert!(attachments.is_empty());
}
other => panic!("expected Send; got {other:?}"),
}
}
#[tokio::test]
async fn command_send_with_bad_attachment_returns_send_with_attachments() {
let mut e = entry("bad_ext", "send_bad_attachment.sh", Vec::new(), None);
e.commands = vec!["bad".into()];
let reg = registry_with(vec![e]);
let outcome = dispatch_command(®, "bad", "").await;
match outcome {
CommandOutcome::Send { text, attachments } => {
assert_eq!(text, "see this");
assert_eq!(attachments.len(), 1);
}
other => panic!("expected Send; got {other:?}"),
}
}