Skip to content

Commit aeaa5ca

Browse files
authored
test(context): add /context hooks tests for HookExecutor, ContextManager (#1239)
1 parent 72cb299 commit aeaa5ca

File tree

3 files changed

+324
-2
lines changed

3 files changed

+324
-2
lines changed

crates/q_cli/src/cli/chat/context.rs

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,8 @@ impl ContextManager {
521521
self.save_config(global).await
522522
}
523523

524-
/// Run all the currently enabled hooks from both the global and profile contexts
524+
/// Run all the currently enabled hooks from both the global and profile contexts.
525+
/// Skipped hooks (disabled) will not appear in the output.
525526
/// # Arguments
526527
/// * `updates` - output stream to write hook run status to
527528
/// # Returns
@@ -753,6 +754,7 @@ fn validate_profile_name(name: &str) -> Result<()> {
753754
#[cfg(test)]
754755
mod tests {
755756
use super::*;
757+
use crate::cli::chat::hooks::HookTrigger;
756758

757759
// Helper function to create a test ContextManager with Context
758760
pub async fn create_test_context_manager() -> Result<ContextManager> {
@@ -856,4 +858,123 @@ mod tests {
856858

857859
Ok(())
858860
}
861+
862+
#[tokio::test]
863+
async fn test_add_hook() -> Result<()> {
864+
let mut manager = create_test_context_manager().await?;
865+
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
866+
867+
// Test adding hook to profile config
868+
manager.add_hook("test_hook".to_string(), hook.clone(), false).await?;
869+
assert!(manager.profile_config.hooks.contains_key("test_hook"));
870+
871+
// Test adding hook to global config
872+
manager.add_hook("global_hook".to_string(), hook.clone(), true).await?;
873+
assert!(manager.global_config.hooks.contains_key("global_hook"));
874+
875+
// Test adding duplicate hook name
876+
assert!(manager.add_hook("test_hook".to_string(), hook, false).await.is_err());
877+
878+
Ok(())
879+
}
880+
881+
#[tokio::test]
882+
async fn test_remove_hook() -> Result<()> {
883+
let mut manager = create_test_context_manager().await?;
884+
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
885+
886+
manager.add_hook("test_hook".to_string(), hook, false).await?;
887+
888+
// Test removing existing hook
889+
manager.remove_hook("test_hook", false).await?;
890+
assert!(!manager.profile_config.hooks.contains_key("test_hook"));
891+
892+
// Test removing non-existent hook
893+
assert!(manager.remove_hook("test_hook", false).await.is_err());
894+
895+
Ok(())
896+
}
897+
898+
#[tokio::test]
899+
async fn test_set_hook_disabled() -> Result<()> {
900+
let mut manager = create_test_context_manager().await?;
901+
let hook = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
902+
903+
manager.add_hook("test_hook".to_string(), hook, false).await?;
904+
905+
// Test disabling hook
906+
manager.set_hook_disabled("test_hook", false, true).await?;
907+
assert!(manager.profile_config.hooks.get("test_hook").unwrap().disabled);
908+
909+
// Test enabling hook
910+
manager.set_hook_disabled("test_hook", false, false).await?;
911+
assert!(!manager.profile_config.hooks.get("test_hook").unwrap().disabled);
912+
913+
// Test with non-existent hook
914+
assert!(manager.set_hook_disabled("nonexistent", false, true).await.is_err());
915+
916+
Ok(())
917+
}
918+
919+
#[tokio::test]
920+
async fn test_set_all_hooks_disabled() -> Result<()> {
921+
let mut manager = create_test_context_manager().await?;
922+
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
923+
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
924+
925+
manager.add_hook("hook1".to_string(), hook1, false).await?;
926+
manager.add_hook("hook2".to_string(), hook2, false).await?;
927+
928+
// Test disabling all hooks
929+
manager.set_all_hooks_disabled(false, true).await?;
930+
assert!(manager.profile_config.hooks.values().all(|h| h.disabled));
931+
932+
// Test enabling all hooks
933+
manager.set_all_hooks_disabled(false, false).await?;
934+
assert!(manager.profile_config.hooks.values().all(|h| !h.disabled));
935+
936+
Ok(())
937+
}
938+
939+
#[tokio::test]
940+
async fn test_run_hooks() -> Result<()> {
941+
let mut manager = create_test_context_manager().await?;
942+
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
943+
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
944+
945+
manager.add_hook("hook1".to_string(), hook1, false).await?;
946+
manager.add_hook("hook2".to_string(), hook2, false).await?;
947+
948+
// Create a buffer to capture the output
949+
let mut output = Vec::new();
950+
951+
// Run the hooks
952+
let results = manager.run_hooks(&mut output).await;
953+
assert_eq!(results.len(), 2); // Should include both hooks
954+
955+
Ok(())
956+
}
957+
958+
#[tokio::test]
959+
async fn test_hooks_across_profiles() -> Result<()> {
960+
let mut manager = create_test_context_manager().await?;
961+
let hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
962+
let hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo test".to_string());
963+
964+
manager.add_hook("profile_hook".to_string(), hook1, false).await?;
965+
manager.add_hook("global_hook".to_string(), hook2, true).await?;
966+
967+
let results = manager.run_hooks(&mut Vec::new()).await;
968+
assert_eq!(results.len(), 2); // Should include both hooks
969+
970+
// Create and switch to a new profile
971+
manager.create_profile("test_profile").await?;
972+
manager.switch_profile("test_profile").await?;
973+
974+
let results = manager.run_hooks(&mut Vec::new()).await;
975+
assert_eq!(results.len(), 1); // Should include global hook
976+
assert_eq!(results[0].0.name, "global_hook");
977+
978+
Ok(())
979+
}
859980
}

crates/q_cli/src/cli/chat/conversation_state.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,7 @@ mod tests {
878878
.as_sendable_conversation_state(Some(conversation_start_context.to_string()))
879879
.await;
880880
let hist = s.history.as_ref().unwrap();
881+
#[allow(clippy::match_wildcard_for_single_variants)]
881882
match &hist[0] {
882883
ChatMessage::UserInputMessage(user) => {
883884
assert!(
@@ -886,7 +887,6 @@ mod tests {
886887
user.content
887888
);
888889
},
889-
#[allow(clippy::match_wildcard_for_single_variants)]
890890
_ => panic!("Expected user message."),
891891
}
892892
assert!(

crates/q_cli/src/cli/chat/hooks.rs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,204 @@ impl HookExecutor {
281281
cache.insert(hook.name.clone(), hook_output);
282282
}
283283
}
284+
285+
#[cfg(test)]
286+
mod tests {
287+
use std::str::from_utf8;
288+
use std::time::Duration;
289+
290+
use tokio::time::sleep;
291+
292+
use super::*;
293+
294+
#[test]
295+
fn test_hook_creation() {
296+
let command = "echo 'hello'";
297+
let hook = Hook::new_inline_hook(HookTrigger::PerPrompt, command.to_string());
298+
299+
assert_eq!(hook.r#type, HookType::Inline);
300+
assert!(!hook.disabled);
301+
assert_eq!(hook.timeout_ms, DEFAULT_TIMEOUT_MS);
302+
assert_eq!(hook.max_output_size, DEFAULT_MAX_OUTPUT_SIZE);
303+
assert_eq!(hook.cache_ttl_seconds, DEFAULT_CACHE_TTL_SECONDS);
304+
assert_eq!(hook.command, Some(command.to_string()));
305+
assert_eq!(hook.trigger, HookTrigger::PerPrompt);
306+
assert!(!hook.is_global);
307+
}
308+
309+
#[tokio::test]
310+
async fn test_hook_executor_cached_conversation_start() {
311+
let mut executor = HookExecutor::new();
312+
let mut hook1 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo 'test1'".to_string());
313+
hook1.is_global = true;
314+
315+
let mut hook2 = Hook::new_inline_hook(HookTrigger::ConversationStart, "echo 'test2'".to_string());
316+
hook2.is_global = false;
317+
318+
// First execution should run the command
319+
let mut output = Vec::new();
320+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
321+
322+
assert_eq!(results.len(), 2);
323+
assert!(results[0].1.contains("test1"));
324+
assert!(results[1].1.contains("test2"));
325+
assert!(from_utf8(&output).unwrap().contains("Running 2 hooks"));
326+
327+
// Second execution should use cache
328+
let mut output = Vec::new();
329+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
330+
331+
assert_eq!(results.len(), 2);
332+
assert!(results[0].1.contains("test1"));
333+
assert!(results[1].1.contains("test2"));
334+
assert!(output.is_empty()); // Should not have run the hook, so no output.
335+
}
336+
337+
#[tokio::test]
338+
async fn test_hook_executor_cached_per_prompt() {
339+
let mut executor = HookExecutor::new();
340+
let mut hook1 = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test1'".to_string());
341+
hook1.is_global = true;
342+
hook1.cache_ttl_seconds = 60;
343+
344+
let mut hook2 = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test2'".to_string());
345+
hook2.is_global = false;
346+
hook2.cache_ttl_seconds = 60;
347+
348+
// First execution should run the command
349+
let mut output = Vec::new();
350+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
351+
352+
assert_eq!(results.len(), 2);
353+
assert!(results[0].1.contains("test1"));
354+
assert!(results[1].1.contains("test2"));
355+
assert!(from_utf8(&output).unwrap().contains("Running 2 hooks"));
356+
357+
// Second execution should use cache
358+
let mut output = Vec::new();
359+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
360+
361+
assert_eq!(results.len(), 2);
362+
assert!(results[0].1.contains("test1"));
363+
assert!(results[1].1.contains("test2"));
364+
assert!(output.is_empty()); // Should not have run the hook, so no output.
365+
}
366+
367+
#[tokio::test]
368+
async fn test_hook_executor_not_cached_per_prompt() {
369+
let mut executor = HookExecutor::new();
370+
let mut hook1 = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test1'".to_string());
371+
hook1.is_global = true;
372+
373+
let mut hook2 = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test2'".to_string());
374+
hook2.is_global = false;
375+
376+
// First execution should run the command
377+
let mut output = Vec::new();
378+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
379+
380+
assert_eq!(results.len(), 2);
381+
assert!(results[0].1.contains("test1"));
382+
assert!(results[1].1.contains("test2"));
383+
assert!(from_utf8(&output).unwrap().contains("Running 2 hooks"));
384+
385+
// Second execution should use cache
386+
let mut output = Vec::new();
387+
let results = executor.run_hooks(vec![&hook1, &hook2], &mut output).await;
388+
389+
assert_eq!(results.len(), 2);
390+
assert!(results[0].1.contains("test1"));
391+
assert!(results[1].1.contains("test2"));
392+
assert!(from_utf8(&output).unwrap().contains("Running 2 hooks"));
393+
}
394+
395+
#[tokio::test]
396+
async fn test_hook_timeout() {
397+
let mut executor = HookExecutor::new();
398+
let mut hook = Hook::new_inline_hook(HookTrigger::PerPrompt, "sleep 2".to_string());
399+
hook.timeout_ms = 100; // Set very short timeout
400+
401+
let mut output = Vec::new();
402+
let results = executor.run_hooks(vec![&hook], &mut output).await;
403+
404+
assert_eq!(results.len(), 0); // Should fail due to timeout
405+
}
406+
407+
#[tokio::test]
408+
async fn test_disabled_hook() {
409+
let mut executor = HookExecutor::new();
410+
let mut hook = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test'".to_string());
411+
hook.disabled = true;
412+
413+
let mut output = Vec::new();
414+
let results = executor.run_hooks(vec![&hook], &mut output).await;
415+
416+
assert_eq!(results.len(), 0); // Disabled hook should not run
417+
}
418+
419+
#[tokio::test]
420+
async fn test_cache_expiration() {
421+
let mut executor = HookExecutor::new();
422+
let mut hook = Hook::new_inline_hook(HookTrigger::PerPrompt, "echo 'test'".to_string());
423+
hook.cache_ttl_seconds = 1;
424+
425+
let mut output = Vec::new();
426+
427+
// First execution
428+
let results1 = executor.run_hooks(vec![&hook], &mut output).await;
429+
assert_eq!(results1.len(), 1);
430+
431+
// Wait for cache to expire
432+
sleep(Duration::from_millis(1001)).await;
433+
434+
// Second execution should run command again
435+
let results2 = executor.run_hooks(vec![&hook], &mut output).await;
436+
assert_eq!(results2.len(), 1);
437+
}
438+
439+
#[test]
440+
fn test_hook_cache_storage() {
441+
let mut executor: HookExecutor = HookExecutor::new();
442+
let hook = Hook::new_inline_hook(HookTrigger::PerPrompt, "".to_string());
443+
444+
let cached_hook = CachedHook {
445+
output: "test output".to_string(),
446+
expiry: None,
447+
};
448+
449+
executor.insert_cache(&hook, cached_hook.clone());
450+
451+
assert_eq!(executor.get_cache(&hook), Some("test output".to_string()));
452+
}
453+
454+
#[test]
455+
fn test_hook_cache_storage_expired() {
456+
let mut executor: HookExecutor = HookExecutor::new();
457+
let hook = Hook::new_inline_hook(HookTrigger::PerPrompt, "".to_string());
458+
459+
let cached_hook = CachedHook {
460+
output: "test output".to_string(),
461+
expiry: Some(Instant::now()),
462+
};
463+
464+
executor.insert_cache(&hook, cached_hook.clone());
465+
466+
// Item should not return since it is expired
467+
assert_eq!(executor.get_cache(&hook), None);
468+
}
469+
470+
#[tokio::test]
471+
async fn test_max_output_size() {
472+
let mut executor = HookExecutor::new();
473+
let mut hook = Hook::new_inline_hook(
474+
HookTrigger::PerPrompt,
475+
"for i in {1..1000}; do echo $i; done".to_string(),
476+
);
477+
hook.max_output_size = 100;
478+
479+
let mut output = Vec::new();
480+
let results = executor.run_hooks(vec![&hook], &mut output).await;
481+
482+
assert!(results[0].1.len() <= hook.max_output_size + " ... truncated".len());
483+
}
484+
}

0 commit comments

Comments
 (0)