1use clap::Args;
4use leviath_providers::InferenceRequest;
5use leviath_runtime::{ContextWindow, ProviderRegistry, context_setup};
6use serde::Deserialize;
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10
11use crate::config::Config;
12use leviath_core::manifest::parse_manifest;
13use leviath_core::truncate_at_boundary;
14
15#[derive(Args)]
16pub struct TestArgs {
17 #[arg(value_name = "PATH")]
19 pub path: Option<String>,
20
21 #[arg(short, long)]
23 pub filter: Option<String>,
24
25 #[arg(long)]
27 pub dry_run: bool,
28}
29
30#[derive(Debug, Deserialize)]
32#[allow(dead_code)]
33struct TestCase {
34 name: String,
35 input: String,
36 #[serde(default)]
37 expect_contains: Option<String>,
38 #[serde(default)]
39 expect_tool_call: Option<String>,
40 #[serde(default)]
41 max_tokens: Option<usize>,
42}
43
44#[derive(Debug, Deserialize)]
45struct TestFile {
46 test: Vec<TestCase>,
47}
48
49pub async fn execute(args: TestArgs) -> anyhow::Result<()> {
50 execute_with_registry(args, Box::new(build_registry_from_config)).await
51}
52
53fn build_registry_from_config(config: &Config) -> ProviderRegistry {
57 let mut reg = ProviderRegistry::new();
58
59 if let Some(ref key) = config.providers.anthropic_api_key {
60 reg.register(
61 "anthropic".to_string(),
62 Arc::new(leviath_providers::AnthropicProvider::new(key.clone())),
63 );
64 }
65 if let Some(ref key) = config.providers.openai_api_key {
66 reg.register(
67 "openai".to_string(),
68 Arc::new(leviath_providers::OpenAIProvider::new(key.clone())),
69 );
70 }
71 if let Some(ref key) = config.providers.google_api_key {
72 reg.register(
73 "google".to_string(),
74 Arc::new(leviath_providers::GeminiProvider::new(key.clone())),
75 );
76 }
77 if let Some(ref key) = config.openrouter_api_key {
78 reg.register(
79 "openrouter".to_string(),
80 Arc::new(leviath_providers::OpenRouterProvider::new(key.clone())),
81 );
82 }
83 let ollama_url = config
84 .ollama_base_url
85 .as_deref()
86 .unwrap_or("http://localhost:11434");
87 reg.register(
88 "ollama".to_string(),
89 Arc::new(leviath_providers::OllamaProvider::with_base_url(
90 ollama_url.to_string(),
91 )),
92 );
93
94 reg
95}
96
97async fn execute_with_registry(
115 args: TestArgs,
116 build_registry: Box<dyn FnOnce(&Config) -> ProviderRegistry>,
117) -> anyhow::Result<()> {
118 let path = args.path.unwrap_or_else(|| ".".to_string());
119 tracing::info!(path = %path, "Running agent tests");
120
121 let project_path = Path::new(&path);
122
123 let manifest_path = project_path.join("agent.leviath");
125 if !manifest_path.exists() {
126 anyhow::bail!(
127 "No agent.leviath found in '{}'. Not an agent project.",
128 project_path.display()
129 );
130 }
131
132 let tests_dir = project_path.join("tests");
133 if !tests_dir.exists() {
134 println!("No tests directory found. Create tests/ with .toml or .rhai files.");
135 println!("\nExample test file (tests/basic.toml):");
136 println!(" [[test]]");
137 println!(" name = \"basic_response\"");
138 println!(" input = \"Hello\"");
139 println!(" expect_contains = \"hello\"");
140 return Ok(());
141 }
142
143 if args.dry_run {
144 println!("Dry run mode: validating test structure only (no API calls)\n");
145 }
146
147 let manifest_content = fs::read_to_string(&manifest_path)?;
149 let blueprint = parse_manifest(&manifest_content)?;
150
151 let region_scripts =
155 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
156 .map_err(|e| anyhow::anyhow!(e))?;
157
158 let registry = if !args.dry_run {
159 let config = Config::load()?;
160 Some(build_registry(&config))
161 } else {
162 None
163 };
164
165 let mut total = 0;
166 let mut passed = 0;
167 let mut failed = 0;
168 let mut failures: Vec<String> = Vec::new();
169
170 for entry in fs::read_dir(&tests_dir)?.flatten() {
172 let test_path = entry.path();
173
174 if test_path.extension().and_then(|e| e.to_str()) == Some("toml") {
175 let file_name = test_path
176 .file_name()
177 .and_then(|n| n.to_str())
178 .unwrap_or("unknown");
179
180 println!("Running test file: {}", file_name);
181
182 let content = fs::read_to_string(&test_path)?;
183 let test_file: TestFile = toml::from_str(&content)
184 .map_err(|e| anyhow::anyhow!("Failed to parse test file '{}': {}", file_name, e))?;
185
186 for test_case in &test_file.test {
187 if let Some(ref filter) = args.filter
189 && !test_case.name.contains(filter.as_str())
190 {
191 continue;
192 }
193
194 total += 1;
195
196 if args.dry_run {
197 let test_valid = validate_test_case(test_case);
199 if test_valid {
200 passed += 1;
201 println!(" PASS (dry-run): {}", test_case.name);
202 } else {
203 failed += 1;
204 let msg = format!("{}: test case validation failed", test_case.name);
205 println!(" FAIL (dry-run): {}", msg);
206 failures.push(msg);
207 }
208 } else {
209 let registry = registry
211 .as_ref()
212 .expect("registry should exist in non-dry-run");
213 match run_test_case(&blueprint, registry, test_case, ®ion_scripts).await {
214 Ok(true) => {
215 passed += 1;
216 println!(" PASS: {}", test_case.name);
217 }
218 Ok(false) => {
219 failed += 1;
220 let msg = format!("{}: assertions failed", test_case.name);
221 println!(" FAIL: {}", msg);
222 failures.push(msg);
223 }
224 Err(e) => {
225 failed += 1;
226 let msg = format!("{}: {}", test_case.name, e);
227 println!(" FAIL: {}", msg);
228 failures.push(msg);
229 }
230 }
231 }
232 }
233 } else if test_path.extension().and_then(|e| e.to_str()) == Some("rhai") {
234 let file_name = test_path
235 .file_name()
236 .and_then(|n| n.to_str())
237 .unwrap_or("unknown");
238
239 if let Some(ref filter) = args.filter
241 && !file_name.contains(filter.as_str())
242 {
243 continue;
244 }
245
246 total += 1;
247 println!("Running script: {}", file_name);
248
249 let script = fs::read_to_string(&test_path)?;
250 let engine = leviath_scripting::ScriptEngine::new();
251 let mut scope = rhai::Scope::new();
252
253 match engine.execute(&script, &mut scope) {
254 Ok(result) => {
255 if let Ok(success) = result.as_bool() {
256 if success {
257 passed += 1;
258 println!(" PASS: {}", file_name);
259 } else {
260 failed += 1;
261 let msg = format!("{}: script returned false", file_name);
262 println!(" FAIL: {}", msg);
263 failures.push(msg);
264 }
265 } else {
266 passed += 1;
267 println!(" PASS: {} (returned: {})", file_name, result);
268 }
269 }
270 Err(e) => {
271 failed += 1;
272 let msg = format!("{}: {}", file_name, e);
273 println!(" FAIL: {}", msg);
274 failures.push(msg);
275 }
276 }
277 }
278 }
279
280 println!("\n--- Results ---");
282 println!("{} passed, {} failed, {} total", passed, failed, total);
283
284 if !failures.is_empty() {
285 println!("\nFailures:");
286 for f in &failures {
287 println!(" - {}", f);
288 }
289 anyhow::bail!("{} test(s) failed", failed);
290 }
291
292 if total == 0 {
293 println!("No test files found in tests/ directory.");
294 }
295
296 Ok(())
297}
298
299async fn run_test_case(
302 blueprint: &leviath_core::Blueprint,
303 registry: &ProviderRegistry,
304 test: &TestCase,
305 region_scripts: &std::collections::HashMap<
306 String,
307 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
308 >,
309) -> anyhow::Result<bool> {
310 let stage = blueprint
312 .stages
313 .first()
314 .ok_or(anyhow::anyhow!("Blueprint has no stages"))?;
315 let provider_name = stage.model.provider();
316 let model_name = stage.model.model();
317
318 let provider = registry.get(provider_name).ok_or_else(|| {
319 anyhow::anyhow!(
320 "Provider '{}' is not configured. Set API key in ~/.leviath/config.toml",
321 provider_name
322 )
323 })?;
324
325 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
330 window.region_scripts = region_scripts.clone();
331 context_setup::init_window(&mut window, blueprint, &test.input);
332
333 let assembled = window.assemble_with_meta(&leviath_runtime::custom_region::AssembleMeta {
336 stage_name: stage.name.clone(),
337 stage_iterations: 0,
338 model: model_name.to_string(),
339 });
340 let caps = provider.capabilities(model_name);
341 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
342 let temperature = if caps.supports_temperature { 0.7 } else { 0.0 };
343 let request = InferenceRequest {
344 system: assembled.system_blocks,
345 messages: assembled.messages,
346 model: model_name.to_string(),
347 max_tokens: remaining.min(caps.max_output_tokens),
348 temperature,
349 tools: Vec::new(),
351 extra: serde_json::Value::Null,
352 request_timeout_secs: None,
353 };
354
355 let response = provider
356 .infer(request)
357 .await
358 .map_err(|e| anyhow::anyhow!("Inference failed: {}", e))?;
359
360 let mut all_passed = true;
362
363 if let Some(ref expected) = test.expect_contains {
364 let content_lower = response.content.to_lowercase();
365 let expected_lower = expected.to_lowercase();
366 if !content_lower.contains(&expected_lower) {
367 println!(
368 " expect_contains failed: response does not contain '{}'",
369 expected
370 );
371 println!(" response: {}", truncate_str(&response.content, 200));
372 all_passed = false;
373 }
374 }
375
376 if let Some(ref expected_tool) = test.expect_tool_call {
377 let has_tool = response
378 .tool_calls
379 .iter()
380 .any(|tc| tc.name == *expected_tool);
381 if !has_tool {
382 println!(
383 " expect_tool_call failed: no tool call to '{}'",
384 expected_tool
385 );
386 let tool_names: Vec<&str> = response
387 .tool_calls
388 .iter()
389 .map(|tc| tc.name.as_str())
390 .collect();
391 println!(" actual tool calls: {:?}", tool_names);
392 all_passed = false;
393 }
394 }
395
396 Ok(all_passed)
397}
398
399fn validate_test_case(test: &TestCase) -> bool {
401 if test.name.is_empty() {
402 return false;
403 }
404 if test.input.is_empty() {
405 return false;
406 }
407 if test.expect_contains.is_none() && test.expect_tool_call.is_none() {
409 return false;
410 }
411 true
412}
413
414fn truncate_str(s: &str, max: usize) -> String {
419 if s.len() <= max {
420 s.to_string()
421 } else {
422 format!("{}...", truncate_at_boundary(s, max))
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::test_support::{with_tracing, write_test_agent};
430
431 #[test]
434 fn validate_test_case_valid_with_expect_contains() {
435 let tc = TestCase {
436 name: "basic".to_string(),
437 input: "hello".to_string(),
438 expect_contains: Some("world".to_string()),
439 expect_tool_call: None,
440 max_tokens: None,
441 };
442 assert!(validate_test_case(&tc));
443 }
444
445 #[test]
446 fn validate_test_case_valid_with_expect_tool_call() {
447 let tc = TestCase {
448 name: "tool_test".to_string(),
449 input: "do something".to_string(),
450 expect_contains: None,
451 expect_tool_call: Some("bash".to_string()),
452 max_tokens: None,
453 };
454 assert!(validate_test_case(&tc));
455 }
456
457 #[test]
458 fn validate_test_case_valid_with_both_assertions() {
459 let tc = TestCase {
460 name: "both".to_string(),
461 input: "test".to_string(),
462 expect_contains: Some("output".to_string()),
463 expect_tool_call: Some("read_file".to_string()),
464 max_tokens: Some(100),
465 };
466 assert!(validate_test_case(&tc));
467 }
468
469 #[test]
470 fn validate_test_case_empty_name_fails() {
471 let tc = TestCase {
472 name: String::new(),
473 input: "hello".to_string(),
474 expect_contains: Some("world".to_string()),
475 expect_tool_call: None,
476 max_tokens: None,
477 };
478 assert!(!validate_test_case(&tc));
479 }
480
481 #[test]
482 fn validate_test_case_empty_input_fails() {
483 let tc = TestCase {
484 name: "test".to_string(),
485 input: String::new(),
486 expect_contains: Some("world".to_string()),
487 expect_tool_call: None,
488 max_tokens: None,
489 };
490 assert!(!validate_test_case(&tc));
491 }
492
493 #[test]
494 fn validate_test_case_no_assertions_fails() {
495 let tc = TestCase {
496 name: "test".to_string(),
497 input: "hello".to_string(),
498 expect_contains: None,
499 expect_tool_call: None,
500 max_tokens: None,
501 };
502 assert!(!validate_test_case(&tc));
503 }
504
505 #[test]
508 fn truncate_str_short() {
509 assert_eq!(truncate_str("hello", 10), "hello");
510 }
511
512 #[test]
513 fn truncate_str_exact() {
514 assert_eq!(truncate_str("hello", 5), "hello");
515 }
516
517 #[test]
518 fn truncate_str_long() {
519 assert_eq!(truncate_str("hello world", 5), "hello...");
520 }
521
522 #[test]
523 fn truncate_str_empty() {
524 assert_eq!(truncate_str("", 5), "");
525 }
526
527 #[test]
530 fn parse_test_file_toml() {
531 let toml_content = r#"
532[[test]]
533name = "greeting"
534input = "Say hello"
535expect_contains = "hello"
536
537[[test]]
538name = "tool_use"
539input = "Read file.txt"
540expect_tool_call = "read_file"
541max_tokens = 500
542"#;
543 let test_file: TestFile = toml::from_str(toml_content).unwrap();
544 assert_eq!(test_file.test.len(), 2);
545 assert_eq!(test_file.test[0].name, "greeting");
546 assert_eq!(test_file.test[0].input, "Say hello");
547 assert_eq!(test_file.test[0].expect_contains.as_deref(), Some("hello"));
548 assert!(test_file.test[0].expect_tool_call.is_none());
549 assert!(test_file.test[0].max_tokens.is_none());
550
551 assert_eq!(test_file.test[1].name, "tool_use");
552 assert_eq!(
553 test_file.test[1].expect_tool_call.as_deref(),
554 Some("read_file")
555 );
556 assert_eq!(test_file.test[1].max_tokens, Some(500));
557 }
558
559 #[test]
560 fn parse_test_file_minimal() {
561 let toml_content = r#"
562[[test]]
563name = "min"
564input = "test"
565expect_contains = "ok"
566"#;
567 let test_file: TestFile = toml::from_str(toml_content).unwrap();
568 assert_eq!(test_file.test.len(), 1);
569 }
570
571 #[test]
572 fn parse_test_file_invalid_toml_errors() {
573 let result: Result<TestFile, _> = toml::from_str("not valid toml {{{{");
574 assert!(result.is_err());
575 }
576
577 #[tokio::test]
580 async fn dry_run_with_temp_project() {
581 let dir = tempfile::tempdir().unwrap();
582 let project = dir.path();
583
584 let manifest = r#"
586[agent]
587name = "test-agent"
588version = "0.1.0"
589description = "test"
590
591[stages.main]
592model = { provider = "anthropic", model = "claude-sonnet-4-6" }
593"#;
594 write_test_agent(project, manifest);
595
596 let tests_dir = project.join("tests");
598 std::fs::create_dir_all(&tests_dir).unwrap();
599 let test_toml = r#"
600[[test]]
601name = "valid_test"
602input = "hello"
603expect_contains = "world"
604"#;
605 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
606
607 let args = TestArgs {
608 path: Some(project.to_str().unwrap().to_string()),
609 filter: None,
610 dry_run: true,
611 };
612
613 let result = execute(args).await;
614 assert!(result.is_ok());
615 }
616
617 #[tokio::test]
618 async fn dry_run_no_tests_dir() {
619 let dir = tempfile::tempdir().unwrap();
620 let project = dir.path();
621
622 let manifest = r#"
623[agent]
624name = "test-agent"
625version = "0.1.0"
626description = "test"
627
628[stages.main]
629model = { provider = "anthropic", model = "claude-sonnet-4-6" }
630"#;
631 write_test_agent(project, manifest);
632
633 let args = TestArgs {
634 path: Some(project.to_str().unwrap().to_string()),
635 filter: None,
636 dry_run: true,
637 };
638
639 let result = execute(args).await;
641 assert!(result.is_ok());
642 }
643
644 #[tokio::test]
645 async fn execute_no_manifest_errors() {
646 let dir = tempfile::tempdir().unwrap();
647 let args = TestArgs {
648 path: Some(dir.path().to_str().unwrap().to_string()),
649 filter: None,
650 dry_run: true,
651 };
652 let result = execute(args).await;
653 assert!(result.is_err());
654 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
655 }
656
657 #[test]
660 fn test_case_all_fields_from_toml() {
661 let toml_content = r#"
662[[test]]
663name = "full_test"
664input = "full input"
665expect_contains = "expected"
666expect_tool_call = "bash"
667max_tokens = 1000
668"#;
669 let test_file: TestFile = toml::from_str(toml_content).unwrap();
670 let tc = &test_file.test[0];
671 assert_eq!(tc.name, "full_test");
672 assert_eq!(tc.input, "full input");
673 assert_eq!(tc.expect_contains.as_deref(), Some("expected"));
674 assert_eq!(tc.expect_tool_call.as_deref(), Some("bash"));
675 assert_eq!(tc.max_tokens, Some(1000));
676 }
677
678 #[test]
679 fn test_case_minimal_from_toml() {
680 let toml_content = r#"
681[[test]]
682name = "min"
683input = "hello"
684expect_contains = "world"
685"#;
686 let test_file: TestFile = toml::from_str(toml_content).unwrap();
687 let tc = &test_file.test[0];
688 assert!(tc.expect_tool_call.is_none());
689 assert!(tc.max_tokens.is_none());
690 }
691
692 #[test]
693 fn test_file_multiple_cases() {
694 let toml_content = r#"
695[[test]]
696name = "case1"
697input = "a"
698expect_contains = "b"
699
700[[test]]
701name = "case2"
702input = "c"
703expect_tool_call = "read_file"
704
705[[test]]
706name = "case3"
707input = "d"
708expect_contains = "e"
709expect_tool_call = "bash"
710max_tokens = 500
711"#;
712 let test_file: TestFile = toml::from_str(toml_content).unwrap();
713 assert_eq!(test_file.test.len(), 3);
714 }
715
716 #[test]
719 fn validate_test_case_whitespace_name_passes() {
720 let tc = TestCase {
722 name: " ".to_string(),
723 input: "hello".to_string(),
724 expect_contains: Some("world".to_string()),
725 expect_tool_call: None,
726 max_tokens: None,
727 };
728 assert!(validate_test_case(&tc));
729 }
730
731 #[test]
734 fn truncate_str_one_char_max() {
735 assert_eq!(truncate_str("hello", 1), "h...");
736 }
737
738 #[test]
739 fn truncate_str_unicode() {
740 assert_eq!(truncate_str("abcde", 3), "abc...");
741 assert_eq!(truncate_str("abc🎉def", 4), "abc...");
745 assert_eq!(truncate_str("abc🎉def", 6), "abc...");
746 assert_eq!(truncate_str("abc🎉def", 7), "abc🎉...");
748 assert_eq!(truncate_str("🎉🎉", 2), "...");
751 }
752
753 #[tokio::test]
756 async fn dry_run_with_filter_matches() {
757 let dir = tempfile::tempdir().unwrap();
758 let project = dir.path();
759 let manifest = r#"
760[agent]
761name = "test-agent"
762version = "0.1.0"
763description = "test"
764
765[stages.main]
766model = { provider = "anthropic", model = "claude-sonnet-4-6" }
767"#;
768 write_test_agent(project, manifest);
769 let tests_dir = project.join("tests");
770 std::fs::create_dir_all(&tests_dir).unwrap();
771 let test_toml = r#"
772[[test]]
773name = "alpha_test"
774input = "hello"
775expect_contains = "world"
776
777[[test]]
778name = "beta_test"
779input = "hello"
780expect_contains = "world"
781"#;
782 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
783
784 let args = TestArgs {
785 path: Some(project.to_str().unwrap().to_string()),
786 filter: Some("alpha".to_string()),
787 dry_run: true,
788 };
789 let result = execute(args).await;
790 assert!(result.is_ok());
791 }
792
793 #[tokio::test]
794 async fn dry_run_failing_test_case() {
795 let dir = tempfile::tempdir().unwrap();
796 let project = dir.path();
797 let manifest = r#"
798[agent]
799name = "test-agent"
800version = "0.1.0"
801description = "test"
802
803[stages.main]
804model = { provider = "anthropic", model = "claude-sonnet-4-6" }
805"#;
806 write_test_agent(project, manifest);
807 let tests_dir = project.join("tests");
808 std::fs::create_dir_all(&tests_dir).unwrap();
809 let test_toml = r#"
811[[test]]
812name = "bad_test"
813input = "hello"
814"#;
815 std::fs::write(tests_dir.join("fail.toml"), test_toml).unwrap();
816
817 let args = TestArgs {
818 path: Some(project.to_str().unwrap().to_string()),
819 filter: None,
820 dry_run: true,
821 };
822 let result = execute(args).await;
823 assert!(result.is_err()); }
825
826 #[test]
829 fn validate_test_case_with_max_tokens_only_and_no_assertion_fails() {
830 let tc = TestCase {
831 name: "has-max-tokens".to_string(),
832 input: "test".to_string(),
833 expect_contains: None,
834 expect_tool_call: None,
835 max_tokens: Some(500),
836 };
837 assert!(!validate_test_case(&tc));
838 }
839
840 #[test]
841 fn validate_test_case_with_only_tool_call_assertion() {
842 let tc = TestCase {
843 name: "tool-only".to_string(),
844 input: "do it".to_string(),
845 expect_contains: None,
846 expect_tool_call: Some("write_file".to_string()),
847 max_tokens: None,
848 };
849 assert!(validate_test_case(&tc));
850 }
851
852 #[test]
855 fn truncate_str_zero_max() {
856 assert_eq!(truncate_str("hello", 0), "...");
857 }
858
859 #[test]
860 fn truncate_str_large_max() {
861 let s = "short";
862 assert_eq!(truncate_str(s, 1000), "short");
863 }
864
865 #[test]
868 fn parse_test_file_empty_tests_array() {
869 let toml_content = r#"
870test = []
871"#;
872 let test_file: TestFile = toml::from_str(toml_content).unwrap();
873 assert!(test_file.test.is_empty());
874 }
875
876 #[test]
877 fn parse_test_file_missing_test_key_errors() {
878 let result: Result<TestFile, _> = toml::from_str("something_else = 42");
879 assert!(result.is_err());
880 }
881
882 #[tokio::test]
885 async fn dry_run_with_filter_no_match() {
886 let dir = tempfile::tempdir().unwrap();
887 let project = dir.path();
888 let manifest = r#"
889[agent]
890name = "test-agent"
891version = "0.1.0"
892description = "test"
893
894[stages.main]
895model = { provider = "anthropic", model = "claude-sonnet-4-6" }
896"#;
897 write_test_agent(project, manifest);
898 let tests_dir = project.join("tests");
899 std::fs::create_dir_all(&tests_dir).unwrap();
900 let test_toml = r#"
901[[test]]
902name = "alpha_test"
903input = "hello"
904expect_contains = "world"
905"#;
906 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
907
908 let args = TestArgs {
909 path: Some(project.to_str().unwrap().to_string()),
910 filter: Some("nonexistent_filter".to_string()),
911 dry_run: true,
912 };
913 let result = execute(args).await;
915 assert!(result.is_ok());
916 }
917
918 #[tokio::test]
921 async fn dry_run_with_rhai_script_passing() {
922 let dir = tempfile::tempdir().unwrap();
923 let project = dir.path();
924 let manifest = r#"
925[agent]
926name = "test-agent"
927version = "0.1.0"
928description = "test"
929
930[stages.main]
931model = { provider = "anthropic", model = "claude-sonnet-4-6" }
932"#;
933 write_test_agent(project, manifest);
934 let tests_dir = project.join("tests");
935 std::fs::create_dir_all(&tests_dir).unwrap();
936
937 std::fs::write(tests_dir.join("pass_test.rhai"), "true").unwrap();
939
940 let args = TestArgs {
941 path: Some(project.to_str().unwrap().to_string()),
942 filter: None,
943 dry_run: true,
944 };
945 let result = execute(args).await;
946 assert!(result.is_ok());
947 }
948
949 #[tokio::test]
950 async fn dry_run_with_rhai_script_returning_false() {
951 let dir = tempfile::tempdir().unwrap();
952 let project = dir.path();
953 let manifest = r#"
954[agent]
955name = "test-agent"
956version = "0.1.0"
957description = "test"
958
959[stages.main]
960model = { provider = "anthropic", model = "claude-sonnet-4-6" }
961"#;
962 write_test_agent(project, manifest);
963 let tests_dir = project.join("tests");
964 std::fs::create_dir_all(&tests_dir).unwrap();
965
966 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
968
969 let args = TestArgs {
970 path: Some(project.to_str().unwrap().to_string()),
971 filter: None,
972 dry_run: true,
973 };
974 let result = execute(args).await;
975 assert!(result.is_err()); }
977
978 #[tokio::test]
979 async fn dry_run_with_rhai_script_error() {
980 let dir = tempfile::tempdir().unwrap();
981 let project = dir.path();
982 let manifest = r#"
983[agent]
984name = "test-agent"
985version = "0.1.0"
986description = "test"
987
988[stages.main]
989model = { provider = "anthropic", model = "claude-sonnet-4-6" }
990"#;
991 write_test_agent(project, manifest);
992 let tests_dir = project.join("tests");
993 std::fs::create_dir_all(&tests_dir).unwrap();
994
995 std::fs::write(
997 tests_dir.join("error_test.rhai"),
998 "throw \"intentional error\"",
999 )
1000 .unwrap();
1001
1002 let args = TestArgs {
1003 path: Some(project.to_str().unwrap().to_string()),
1004 filter: None,
1005 dry_run: true,
1006 };
1007 let result = execute(args).await;
1008 assert!(result.is_err()); }
1010
1011 #[tokio::test]
1012 async fn dry_run_with_rhai_non_bool_result_passes() {
1013 let dir = tempfile::tempdir().unwrap();
1014 let project = dir.path();
1015 let manifest = r#"
1016[agent]
1017name = "test-agent"
1018version = "0.1.0"
1019description = "test"
1020
1021[stages.main]
1022model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1023"#;
1024 write_test_agent(project, manifest);
1025 let tests_dir = project.join("tests");
1026 std::fs::create_dir_all(&tests_dir).unwrap();
1027
1028 std::fs::write(tests_dir.join("nonbool_test.rhai"), "42").unwrap();
1030
1031 let args = TestArgs {
1032 path: Some(project.to_str().unwrap().to_string()),
1033 filter: None,
1034 dry_run: true,
1035 };
1036 let result = execute(args).await;
1037 assert!(result.is_ok()); }
1039
1040 #[tokio::test]
1041 async fn dry_run_with_rhai_filter_matches() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let project = dir.path();
1044 let manifest = r#"
1045[agent]
1046name = "test-agent"
1047version = "0.1.0"
1048description = "test"
1049
1050[stages.main]
1051model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1052"#;
1053 write_test_agent(project, manifest);
1054 let tests_dir = project.join("tests");
1055 std::fs::create_dir_all(&tests_dir).unwrap();
1056
1057 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1059 std::fs::write(tests_dir.join("good_test.rhai"), "true").unwrap();
1061
1062 let args = TestArgs {
1063 path: Some(project.to_str().unwrap().to_string()),
1064 filter: Some("good".to_string()),
1065 dry_run: true,
1066 };
1067 let result = execute(args).await;
1068 assert!(result.is_ok()); }
1070
1071 #[tokio::test]
1074 async fn dry_run_with_multiple_test_files() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let project = dir.path();
1077 let manifest = r#"
1078[agent]
1079name = "test-agent"
1080version = "0.1.0"
1081description = "test"
1082
1083[stages.main]
1084model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1085"#;
1086 write_test_agent(project, manifest);
1087 let tests_dir = project.join("tests");
1088 std::fs::create_dir_all(&tests_dir).unwrap();
1089
1090 let test1 = r#"
1091[[test]]
1092name = "test_a"
1093input = "hello"
1094expect_contains = "world"
1095"#;
1096 let test2 = r#"
1097[[test]]
1098name = "test_b"
1099input = "foo"
1100expect_tool_call = "bar"
1101"#;
1102 std::fs::write(tests_dir.join("file1.toml"), test1).unwrap();
1103 std::fs::write(tests_dir.join("file2.toml"), test2).unwrap();
1104
1105 let args = TestArgs {
1106 path: Some(project.to_str().unwrap().to_string()),
1107 filter: None,
1108 dry_run: true,
1109 };
1110 let result = execute(args).await;
1111 assert!(result.is_ok());
1112 }
1113
1114 #[tokio::test]
1117 async fn dry_run_with_invalid_toml_file() {
1118 let dir = tempfile::tempdir().unwrap();
1119 let project = dir.path();
1120 let manifest = r#"
1121[agent]
1122name = "test-agent"
1123version = "0.1.0"
1124description = "test"
1125
1126[stages.main]
1127model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1128"#;
1129 write_test_agent(project, manifest);
1130 let tests_dir = project.join("tests");
1131 std::fs::create_dir_all(&tests_dir).unwrap();
1132 std::fs::write(tests_dir.join("bad.toml"), "not valid {{{ toml").unwrap();
1133
1134 let args = TestArgs {
1135 path: Some(project.to_str().unwrap().to_string()),
1136 filter: None,
1137 dry_run: true,
1138 };
1139 let result = execute(args).await;
1140 assert!(result.is_err());
1141 }
1142
1143 use leviath_providers::{
1157 FinishReason, InferenceRequest, InferenceResponse, Provider, TokenUsage, ToolCall,
1158 };
1159
1160 struct MockProvider {
1163 content: String,
1164 tool_calls: Vec<ToolCall>,
1165 }
1166
1167 #[async_trait::async_trait]
1168 impl Provider for MockProvider {
1169 async fn infer(
1170 &self,
1171 _request: InferenceRequest,
1172 ) -> leviath_providers::Result<InferenceResponse> {
1173 Ok(InferenceResponse {
1174 content: self.content.clone(),
1175 tool_calls: self.tool_calls.clone(),
1176 tokens_used: TokenUsage {
1177 prompt_tokens: 1,
1178 completion_tokens: 1,
1179 total_tokens: 2,
1180 cached_tokens: 0,
1181 cache_write_tokens: 0,
1182 },
1183 finish_reason: FinishReason::Complete,
1184 })
1185 }
1186
1187 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1188 text.len()
1189 }
1190
1191 fn max_context_tokens(&self, _model: &str) -> usize {
1192 8192
1193 }
1194
1195 fn name(&self) -> &str {
1196 "mock"
1197 }
1198
1199 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1200 leviath_providers::ModelCapabilities::default()
1201 }
1202 }
1203
1204 struct NoTemperatureProvider;
1207
1208 #[async_trait::async_trait]
1209 impl Provider for NoTemperatureProvider {
1210 async fn infer(
1211 &self,
1212 _request: InferenceRequest,
1213 ) -> leviath_providers::Result<InferenceResponse> {
1214 Ok(InferenceResponse {
1215 content: "cold hello".to_string(),
1216 tool_calls: vec![],
1217 tokens_used: TokenUsage {
1218 prompt_tokens: 1,
1219 completion_tokens: 1,
1220 total_tokens: 2,
1221 cached_tokens: 0,
1222 cache_write_tokens: 0,
1223 },
1224 finish_reason: FinishReason::Complete,
1225 })
1226 }
1227
1228 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1229 text.len()
1230 }
1231
1232 fn max_context_tokens(&self, _model: &str) -> usize {
1233 8192
1234 }
1235
1236 fn name(&self) -> &str {
1237 "no-temperature"
1238 }
1239
1240 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1241 leviath_providers::ModelCapabilities {
1242 supports_temperature: false,
1243 ..Default::default()
1244 }
1245 }
1246 }
1247
1248 struct ErrorProvider;
1250
1251 #[async_trait::async_trait]
1252 impl Provider for ErrorProvider {
1253 async fn infer(
1254 &self,
1255 _request: InferenceRequest,
1256 ) -> leviath_providers::Result<InferenceResponse> {
1257 Err(leviath_providers::ProviderError::ApiError(
1258 "simulated inference error".to_string(),
1259 ))
1260 }
1261
1262 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1263 text.len()
1264 }
1265
1266 fn max_context_tokens(&self, _model: &str) -> usize {
1267 8192
1268 }
1269
1270 fn name(&self) -> &str {
1271 "error-provider"
1272 }
1273
1274 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1275 leviath_providers::ModelCapabilities::default()
1276 }
1277 }
1278
1279 fn basic_blueprint() -> leviath_core::Blueprint {
1280 let manifest = r#"
1281[agent]
1282name = "test-agent"
1283version = "0.1.0"
1284description = "test"
1285
1286[stages.main]
1287model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1288"#;
1289 parse_manifest(manifest).unwrap()
1290 }
1291
1292 fn blueprint_with_tool_results_region() -> leviath_core::Blueprint {
1295 let manifest = r#"
1296[agent]
1297name = "test-agent"
1298version = "0.1.0"
1299description = "test"
1300
1301[stages.main]
1302model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1303
1304[context.regions.tool_results]
1305kind = "temporary"
1306max_tokens = 5000
1307"#;
1308 parse_manifest(manifest).unwrap()
1309 }
1310
1311 struct RecordingProvider {
1315 seen: std::sync::Arc<std::sync::Mutex<Option<InferenceRequest>>>,
1316 }
1317
1318 #[async_trait::async_trait]
1319 impl Provider for RecordingProvider {
1320 async fn infer(
1321 &self,
1322 request: InferenceRequest,
1323 ) -> leviath_providers::Result<InferenceResponse> {
1324 *self.seen.lock().unwrap() = Some(request);
1325 Ok(InferenceResponse {
1326 content: "recorded".to_string(),
1327 tool_calls: vec![],
1328 tokens_used: TokenUsage {
1329 prompt_tokens: 1,
1330 completion_tokens: 1,
1331 total_tokens: 2,
1332 cached_tokens: 0,
1333 cache_write_tokens: 0,
1334 },
1335 finish_reason: FinishReason::Complete,
1336 })
1337 }
1338
1339 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1340 text.len()
1341 }
1342
1343 fn max_context_tokens(&self, _model: &str) -> usize {
1344 8192
1345 }
1346
1347 fn name(&self) -> &str {
1348 "recording"
1349 }
1350
1351 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1352 leviath_providers::ModelCapabilities::default()
1353 }
1354 }
1355
1356 #[tokio::test]
1359 async fn run_test_case_renders_custom_region_through_its_script() {
1360 let manifest = r#"
1361[agent]
1362name = "custom-test-agent"
1363version = "0.1.0"
1364description = "test"
1365
1366[stages.main]
1367model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1368
1369[context.regions.task]
1370kind = "pinned"
1371max_tokens = 4000
1372
1373[context.regions.brain]
1374kind = "custom"
1375script = "hooks/brain.rhai"
1376max_tokens = 4000
1377"#;
1378 let blueprint = parse_manifest(manifest).unwrap();
1379 let scripts = std::collections::HashMap::from([(
1380 "hooks/brain.rhai".to_string(),
1381 std::sync::Arc::new(
1382 leviath_scripting::region_hook::compile(
1383 "hooks/brain.rhai",
1384 "fn render(ctx) { `<brain stage=${ctx.stage_name} model=${ctx.model}>` }",
1385 )
1386 .unwrap(),
1387 ),
1388 )]);
1389 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1390 let mut registry = ProviderRegistry::new();
1391 registry.register(
1392 "anthropic".to_string(),
1393 Arc::new(RecordingProvider { seen: seen.clone() }),
1394 );
1395 let tc = TestCase {
1396 name: "custom_render".to_string(),
1397 input: "hi".to_string(),
1398 expect_contains: Some("recorded".to_string()),
1399 expect_tool_call: None,
1400 max_tokens: None,
1401 };
1402 let passed = run_test_case(&blueprint, ®istry, &tc, &scripts)
1403 .await
1404 .unwrap();
1405 assert!(passed);
1406 let request = seen.lock().unwrap().take().expect("provider saw a request");
1407 let system_texts: Vec<&String> = request.system.iter().map(|b| &b.text).collect();
1409 let rendered = system_texts
1410 .iter()
1411 .any(|t| t.as_str() == "<brain stage=main model=claude-sonnet-4-6>");
1412 assert!(
1413 rendered,
1414 "custom region rendered with stage metadata; system blocks: {system_texts:?}"
1415 );
1416
1417 let provider = registry.get("anthropic").unwrap();
1419 assert_eq!(provider.count_tokens("abcd", "m").await, 4);
1420 assert_eq!(provider.max_context_tokens("m"), 8192);
1421 assert_eq!(provider.name(), "recording");
1422 }
1423
1424 #[tokio::test]
1427 async fn execute_fails_fast_on_a_broken_custom_region_script() {
1428 let dir = tempfile::tempdir().unwrap();
1429 let project = dir.path();
1430 std::fs::write(
1431 project.join("agent.leviath"),
1432 r#"
1433[agent]
1434name = "broken-custom"
1435version = "0.1.0"
1436description = "d"
1437
1438[stages.main]
1439model = { provider = "anthropic", model = "m" }
1440
1441[context.regions.brain]
1442kind = "custom"
1443script = "hooks/missing.rhai"
1444max_tokens = 4000
1445"#,
1446 )
1447 .unwrap();
1448 std::fs::create_dir(project.join("tests")).unwrap();
1449 std::fs::write(
1450 project.join("tests/basic.toml"),
1451 "[[test]]\nname = \"t\"\ninput = \"hi\"\n",
1452 )
1453 .unwrap();
1454 let args = TestArgs {
1455 path: Some(project.to_str().unwrap().to_string()),
1456 filter: None,
1457 dry_run: true,
1458 };
1459 let err = execute(args).await.unwrap_err().to_string();
1460 assert!(err.contains("region 'brain'"), "{err}");
1461 assert!(err.contains("hooks/missing.rhai"), "{err}");
1462 }
1463
1464 #[tokio::test]
1469 async fn run_test_case_inference_error_propagates() {
1470 let blueprint = basic_blueprint();
1471 let mut registry = ProviderRegistry::new();
1472 registry.register("anthropic".to_string(), Arc::new(ErrorProvider));
1473 let tc = TestCase {
1474 name: "inference_error".to_string(),
1475 input: "hi".to_string(),
1476 expect_contains: Some("x".to_string()),
1477 expect_tool_call: None,
1478 max_tokens: None,
1479 };
1480 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1481 let err = result.unwrap_err().to_string();
1482 assert!(err.contains("Inference failed"));
1483 }
1484
1485 #[tokio::test]
1487 async fn run_test_case_blueprint_with_no_stages_errors() {
1488 use leviath_core::{Blueprint, layout::ContextLayout};
1489 let blueprint = Blueprint::new(
1490 "no-stages".to_string(),
1491 "test".to_string(),
1492 vec![],
1493 ContextLayout::new(vec![], 4096),
1494 );
1495 let registry = ProviderRegistry::new();
1496 let tc = TestCase {
1497 name: "no_stages".to_string(),
1498 input: "hi".to_string(),
1499 expect_contains: Some("x".to_string()),
1500 expect_tool_call: None,
1501 max_tokens: None,
1502 };
1503 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1504 let err = result.unwrap_err().to_string();
1505 assert!(err.contains("Blueprint has no stages"));
1506 }
1507
1508 #[tokio::test]
1511 async fn run_test_case_with_preexisting_tool_results_region() {
1512 let blueprint = blueprint_with_tool_results_region();
1513 let mut registry = ProviderRegistry::new();
1514 registry.register(
1515 "anthropic".to_string(),
1516 Arc::new(MockProvider {
1517 content: "hello world".to_string(),
1518 tool_calls: vec![],
1519 }),
1520 );
1521 let tc = TestCase {
1522 name: "has_tool_results_region".to_string(),
1523 input: "hi".to_string(),
1524 expect_contains: Some("world".to_string()),
1525 expect_tool_call: None,
1526 max_tokens: None,
1527 };
1528 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1529 assert!(result.unwrap());
1530 }
1531
1532 #[tokio::test]
1536 async fn execute_with_registry_manifest_unreadable_errors() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let project = dir.path();
1539 std::fs::create_dir_all(project.join("agent.leviath")).unwrap();
1540 let tests_dir = project.join("tests");
1541 std::fs::create_dir_all(&tests_dir).unwrap();
1542 let args = TestArgs {
1543 path: Some(project.to_str().unwrap().to_string()),
1544 filter: None,
1545 dry_run: true,
1546 };
1547 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1548 assert!(result.is_err());
1549 }
1550
1551 #[tokio::test]
1556 async fn execute_with_registry_config_load_fails_errors() {
1557 let dir = tempfile::tempdir().unwrap();
1558 let project = dir.path();
1559 let manifest = r#"
1560[agent]
1561name = "test-agent"
1562version = "0.1.0"
1563description = "test"
1564
1565[stages.main]
1566model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1567"#;
1568 write_test_agent(project, manifest);
1569 let tests_dir = project.join("tests");
1570 std::fs::create_dir_all(&tests_dir).unwrap();
1571
1572 crate::config::with_isolated_config_path_async(
1574 "test-cmd-config-fail",
1575 |fake_dir| async move {
1576 let bad_config = fake_dir.join("config.toml");
1577 std::fs::write(&bad_config, "not valid toml {{{").unwrap();
1578
1579 let args = TestArgs {
1580 path: Some(project.to_str().unwrap().to_string()),
1581 filter: None,
1582 dry_run: false, };
1584 let result =
1585 execute_with_registry(args, Box::new(build_registry_from_config)).await;
1586 assert!(result.is_err());
1587 },
1588 )
1589 .await;
1590 }
1591
1592 #[tokio::test]
1595 async fn execute_with_registry_manifest_invalid_toml_errors() {
1596 let dir = tempfile::tempdir().unwrap();
1597 let project = dir.path();
1598 std::fs::write(project.join("agent.leviath"), "not valid toml {{{").unwrap();
1599 let tests_dir = project.join("tests");
1600 std::fs::create_dir_all(&tests_dir).unwrap();
1601 let args = TestArgs {
1602 path: Some(project.to_str().unwrap().to_string()),
1603 filter: None,
1604 dry_run: false,
1605 };
1606 let result =
1607 execute_with_registry(args, Box::new(mock_registry_builder("irrelevant", vec![])))
1608 .await;
1609 assert!(result.is_err());
1610 }
1611
1612 #[tokio::test]
1615 async fn execute_with_registry_tests_dir_unreadable_errors() {
1616 let dir = tempfile::tempdir().unwrap();
1617 let project = dir.path();
1618 let manifest = r#"
1619[agent]
1620name = "test-agent"
1621version = "0.1.0"
1622description = "test"
1623
1624[stages.main]
1625model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1626"#;
1627 write_test_agent(project, manifest);
1628 std::fs::write(project.join("tests"), "not a dir").unwrap();
1630 let args = TestArgs {
1631 path: Some(project.to_str().unwrap().to_string()),
1632 filter: None,
1633 dry_run: true,
1634 };
1635 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1636 assert!(result.is_err());
1637 }
1638
1639 #[tokio::test]
1643 async fn execute_with_registry_toml_unreadable_errors() {
1644 let dir = tempfile::tempdir().unwrap();
1645 let project = dir.path();
1646 let manifest = r#"
1647[agent]
1648name = "test-agent"
1649version = "0.1.0"
1650description = "test"
1651
1652[stages.main]
1653model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1654"#;
1655 write_test_agent(project, manifest);
1656 let tests_dir = project.join("tests");
1657 std::fs::create_dir_all(&tests_dir).unwrap();
1658 std::fs::create_dir_all(tests_dir.join("unreadable.toml")).unwrap();
1659 let args = TestArgs {
1660 path: Some(project.to_str().unwrap().to_string()),
1661 filter: None,
1662 dry_run: true,
1663 };
1664 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1665 assert!(result.is_err());
1666 }
1667
1668 #[tokio::test]
1672 async fn execute_with_registry_rhai_unreadable_errors() {
1673 let dir = tempfile::tempdir().unwrap();
1674 let project = dir.path();
1675 let manifest = r#"
1676[agent]
1677name = "test-agent"
1678version = "0.1.0"
1679description = "test"
1680
1681[stages.main]
1682model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1683"#;
1684 write_test_agent(project, manifest);
1685 let tests_dir = project.join("tests");
1686 std::fs::create_dir_all(&tests_dir).unwrap();
1687 std::fs::create_dir_all(tests_dir.join("unreadable.rhai")).unwrap();
1688 let args = TestArgs {
1689 path: Some(project.to_str().unwrap().to_string()),
1690 filter: None,
1691 dry_run: true,
1692 };
1693 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1694 assert!(result.is_err());
1695 }
1696
1697 #[tokio::test]
1700 async fn run_test_case_with_no_pinned_region_still_runs() {
1701 use leviath_core::Blueprint;
1702 use leviath_core::layout::ContextLayout;
1703 let blueprint = Blueprint::new(
1704 "no-regions".to_string(),
1705 "test".to_string(),
1706 vec![leviath_core::Stage::new(
1707 "main".to_string(),
1708 leviath_core::blueprint::ModelConfig::new(
1709 "anthropic".to_string(),
1710 "claude-sonnet-4-6".to_string(),
1711 ),
1712 )],
1713 ContextLayout::new(vec![], 4096),
1714 );
1715 let mut registry = ProviderRegistry::new();
1716 registry.register(
1717 "anthropic".to_string(),
1718 Arc::new(MockProvider {
1719 content: "hello world".to_string(),
1720 tool_calls: vec![],
1721 }),
1722 );
1723 let tc = TestCase {
1724 name: "no_pinned".to_string(),
1725 input: "hi".to_string(),
1726 expect_contains: Some("world".to_string()),
1727 expect_tool_call: None,
1728 max_tokens: None,
1729 };
1730 assert!(
1731 run_test_case(&blueprint, ®istry, &tc, &Default::default())
1732 .await
1733 .unwrap()
1734 );
1735 }
1736
1737 #[tokio::test]
1738 async fn run_test_case_passes_with_expect_contains() {
1739 let blueprint = basic_blueprint();
1740 let mut registry = ProviderRegistry::new();
1741 registry.register(
1742 "anthropic".to_string(),
1743 Arc::new(MockProvider {
1744 content: "Hello, world!".to_string(),
1745 tool_calls: vec![],
1746 }),
1747 );
1748
1749 let tc = TestCase {
1750 name: "greeting".to_string(),
1751 input: "say hello".to_string(),
1752 expect_contains: Some("world".to_string()),
1753 expect_tool_call: None,
1754 max_tokens: None,
1755 };
1756
1757 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1758 assert!(result.unwrap());
1759 }
1760
1761 #[tokio::test]
1762 async fn run_test_case_fails_expect_contains_mismatch() {
1763 let blueprint = basic_blueprint();
1764 let mut registry = ProviderRegistry::new();
1765 registry.register(
1766 "anthropic".to_string(),
1767 Arc::new(MockProvider {
1768 content: "Goodbye".to_string(),
1769 tool_calls: vec![],
1770 }),
1771 );
1772
1773 let tc = TestCase {
1774 name: "greeting".to_string(),
1775 input: "say hello".to_string(),
1776 expect_contains: Some("world".to_string()),
1777 expect_tool_call: None,
1778 max_tokens: None,
1779 };
1780
1781 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1782 assert!(!result.unwrap());
1783 }
1784
1785 #[tokio::test]
1786 async fn run_test_case_passes_with_expect_tool_call() {
1787 let blueprint = basic_blueprint();
1788 let mut registry = ProviderRegistry::new();
1789 registry.register(
1790 "anthropic".to_string(),
1791 Arc::new(MockProvider {
1792 content: String::new(),
1793 tool_calls: vec![ToolCall {
1794 id: "call_1".to_string(),
1795 name: "bash".to_string(),
1796 arguments: serde_json::json!({}),
1797 thought_signature: None,
1798 }],
1799 }),
1800 );
1801
1802 let tc = TestCase {
1803 name: "tool_test".to_string(),
1804 input: "run a command".to_string(),
1805 expect_contains: None,
1806 expect_tool_call: Some("bash".to_string()),
1807 max_tokens: None,
1808 };
1809
1810 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1811 assert!(result.unwrap());
1812 }
1813
1814 #[tokio::test]
1815 async fn run_test_case_fails_expect_tool_call_missing() {
1816 let blueprint = basic_blueprint();
1817 let mut registry = ProviderRegistry::new();
1818 registry.register(
1819 "anthropic".to_string(),
1820 Arc::new(MockProvider {
1821 content: "no tools here".to_string(),
1822 tool_calls: vec![ToolCall {
1828 id: "call_1".to_string(),
1829 name: "write_file".to_string(),
1830 arguments: serde_json::json!({}),
1831 thought_signature: None,
1832 }],
1833 }),
1834 );
1835
1836 let tc = TestCase {
1837 name: "tool_test".to_string(),
1838 input: "run a command".to_string(),
1839 expect_contains: None,
1840 expect_tool_call: Some("bash".to_string()),
1841 max_tokens: None,
1842 };
1843
1844 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1845 assert!(!result.unwrap());
1846 }
1847
1848 #[tokio::test]
1849 async fn run_test_case_fails_both_assertions() {
1850 let blueprint = basic_blueprint();
1851 let mut registry = ProviderRegistry::new();
1852 registry.register(
1853 "anthropic".to_string(),
1854 Arc::new(MockProvider {
1855 content: "unrelated content".to_string(),
1856 tool_calls: vec![],
1857 }),
1858 );
1859
1860 let tc = TestCase {
1861 name: "both".to_string(),
1862 input: "do stuff".to_string(),
1863 expect_contains: Some("expected".to_string()),
1864 expect_tool_call: Some("write_file".to_string()),
1865 max_tokens: None,
1866 };
1867
1868 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1869 assert!(!result.unwrap());
1870 }
1871
1872 #[tokio::test]
1873 async fn run_test_case_no_assertions_always_passes() {
1874 let blueprint = basic_blueprint();
1875 let mut registry = ProviderRegistry::new();
1876 registry.register(
1877 "anthropic".to_string(),
1878 Arc::new(MockProvider {
1879 content: "anything".to_string(),
1880 tool_calls: vec![],
1881 }),
1882 );
1883
1884 let tc = TestCase {
1885 name: "no_assertions".to_string(),
1886 input: "hi".to_string(),
1887 expect_contains: None,
1888 expect_tool_call: None,
1889 max_tokens: None,
1890 };
1891
1892 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1893 assert!(result.unwrap());
1894 }
1895
1896 #[tokio::test]
1897 async fn run_test_case_provider_not_registered_errors() {
1898 let blueprint = basic_blueprint();
1899 let registry = ProviderRegistry::new(); let tc = TestCase {
1902 name: "no_provider".to_string(),
1903 input: "hi".to_string(),
1904 expect_contains: Some("x".to_string()),
1905 expect_tool_call: None,
1906 max_tokens: None,
1907 };
1908
1909 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1910 let err = result.unwrap_err().to_string();
1911 assert!(err.contains("not configured"));
1912 }
1913
1914 #[tokio::test]
1915 async fn no_temperature_provider_metadata_is_exercised() {
1916 let p = NoTemperatureProvider;
1917 assert_eq!(p.name(), "no-temperature");
1918 assert_eq!(p.count_tokens("abcd", "m").await, 4);
1919 assert_eq!(p.max_context_tokens("m"), 8192);
1920 }
1921
1922 #[tokio::test]
1923 async fn run_test_case_omits_temperature_when_provider_lacks_it() {
1924 let blueprint = basic_blueprint();
1925 let mut registry = ProviderRegistry::new();
1926 registry.register("anthropic".to_string(), Arc::new(NoTemperatureProvider));
1927 let tc = TestCase {
1928 name: "no_temp".to_string(),
1929 input: "hi".to_string(),
1930 expect_contains: Some("cold".to_string()),
1931 expect_tool_call: None,
1932 max_tokens: None,
1933 };
1934 assert!(
1935 run_test_case(&blueprint, ®istry, &tc, &Default::default())
1936 .await
1937 .unwrap()
1938 );
1939 }
1940
1941 #[tokio::test]
1942 async fn run_test_case_long_input_runs() {
1943 let blueprint = basic_blueprint();
1945 let mut registry = ProviderRegistry::new();
1946 registry.register(
1947 "anthropic".to_string(),
1948 Arc::new(MockProvider {
1949 content: "response text mentioning keyword".to_string(),
1950 tool_calls: vec![],
1951 }),
1952 );
1953
1954 let tc = TestCase {
1955 name: "long_input".to_string(),
1956 input: "x".repeat(500),
1957 expect_contains: Some("keyword".to_string()),
1958 expect_tool_call: None,
1959 max_tokens: None,
1960 };
1961
1962 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1963 assert!(result.unwrap());
1964 }
1965
1966 fn mock_registry_builder(
1987 content: &'static str,
1988 tool_calls: Vec<ToolCall>,
1989 ) -> impl FnOnce(&Config) -> ProviderRegistry {
1990 move |_config: &Config| {
1991 let mut reg = ProviderRegistry::new();
1992 reg.register(
1993 "anthropic".to_string(),
1994 Arc::new(MockProvider {
1995 content: content.to_string(),
1996 tool_calls,
1997 }),
1998 );
1999 reg
2000 }
2001 }
2002
2003 fn write_project_with_test_file(project: &std::path::Path, test_toml: &str) {
2004 let manifest = r#"
2005[agent]
2006name = "test-agent"
2007version = "0.1.0"
2008description = "test"
2009
2010[stages.main]
2011model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2012"#;
2013 write_test_agent(project, manifest);
2014 let tests_dir = project.join("tests");
2015 std::fs::create_dir_all(&tests_dir).unwrap();
2016 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
2017 }
2018
2019 #[tokio::test]
2020 async fn execute_with_registry_non_dry_run_all_pass() {
2021 crate::config::with_isolated_config_path_async(
2022 "test-rs-non-dry-run-all-pass",
2023 |_fake_dir| async move {
2024 let dir = tempfile::tempdir().unwrap();
2025 let project = dir.path();
2026 write_project_with_test_file(
2027 project,
2028 r#"
2029[[test]]
2030name = "greeting"
2031input = "say hello"
2032expect_contains = "world"
2033"#,
2034 );
2035
2036 let args = TestArgs {
2037 path: Some(project.to_str().unwrap().to_string()),
2038 filter: None,
2039 dry_run: false,
2040 };
2041
2042 let result = with_tracing(|| {
2043 execute_with_registry(
2044 args,
2045 Box::new(mock_registry_builder("Hello, world!", vec![])),
2046 )
2047 })
2048 .await;
2049 assert!(result.is_ok());
2050 },
2051 )
2052 .await;
2053 }
2054
2055 #[tokio::test]
2056 async fn execute_with_registry_none_path_defaults_to_current_dir() {
2057 let args = TestArgs {
2064 path: None,
2065 filter: None,
2066 dry_run: true,
2067 };
2068 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2069 assert!(result.is_err());
2070 assert!(
2071 result
2072 .unwrap_err()
2073 .to_string()
2074 .contains("No agent.leviath found")
2075 );
2076 }
2077
2078 #[tokio::test]
2079 async fn execute_with_registry_non_dry_run_failure_bails_with_count() {
2080 crate::config::with_isolated_config_path_async(
2081 "test-rs-non-dry-run-failure-bails-with-count",
2082 |_fake_dir| async move {
2083 let dir = tempfile::tempdir().unwrap();
2084 let project = dir.path();
2085 write_project_with_test_file(
2086 project,
2087 r#"
2088[[test]]
2089name = "greeting"
2090input = "say hello"
2091expect_contains = "world"
2092"#,
2093 );
2094
2095 let args = TestArgs {
2096 path: Some(project.to_str().unwrap().to_string()),
2097 filter: None,
2098 dry_run: false,
2099 };
2100
2101 let result =
2102 execute_with_registry(args, Box::new(mock_registry_builder("goodbye", vec![])))
2103 .await;
2104 let err = result.unwrap_err().to_string();
2105 assert!(err.contains("1 test(s) failed"));
2106 },
2107 )
2108 .await;
2109 }
2110
2111 #[tokio::test]
2112 async fn execute_with_registry_non_dry_run_applies_filter() {
2113 crate::config::with_isolated_config_path_async(
2114 "test-rs-non-dry-run-applies-filter",
2115 |_fake_dir| async move {
2116 let dir = tempfile::tempdir().unwrap();
2117 let project = dir.path();
2118 write_project_with_test_file(
2119 project,
2120 r#"
2121[[test]]
2122name = "keep_me"
2123input = "say hello"
2124expect_contains = "world"
2125
2126[[test]]
2127name = "skip_me"
2128input = "say hello"
2129expect_contains = "unmatchable content"
2130"#,
2131 );
2132
2133 let args = TestArgs {
2134 path: Some(project.to_str().unwrap().to_string()),
2135 filter: Some("keep".to_string()),
2136 dry_run: false,
2137 };
2138
2139 let result = execute_with_registry(
2143 args,
2144 Box::new(mock_registry_builder("Hello, world!", vec![])),
2145 )
2146 .await;
2147 assert!(result.is_ok());
2148 },
2149 )
2150 .await;
2151 }
2152
2153 #[tokio::test]
2154 async fn execute_with_registry_non_dry_run_tool_call_assertion() {
2155 crate::config::with_isolated_config_path_async(
2156 "test-rs-non-dry-run-tool-call-assertion",
2157 |_fake_dir| async move {
2158 let dir = tempfile::tempdir().unwrap();
2159 let project = dir.path();
2160 write_project_with_test_file(
2161 project,
2162 r#"
2163[[test]]
2164name = "tool_test"
2165input = "run a command"
2166expect_tool_call = "bash"
2167"#,
2168 );
2169
2170 let args = TestArgs {
2171 path: Some(project.to_str().unwrap().to_string()),
2172 filter: None,
2173 dry_run: false,
2174 };
2175
2176 let tool_calls = vec![ToolCall {
2177 id: "call_1".to_string(),
2178 name: "bash".to_string(),
2179 arguments: serde_json::json!({}),
2180 thought_signature: None,
2181 }];
2182 let result =
2183 execute_with_registry(args, Box::new(mock_registry_builder("", tool_calls)))
2184 .await;
2185 assert!(result.is_ok());
2186 },
2187 )
2188 .await;
2189 }
2190
2191 #[tokio::test]
2192 async fn execute_with_registry_non_dry_run_provider_error_counts_as_failure() {
2193 crate::config::with_isolated_config_path_async(
2194 "test-rs-non-dry-run-provider-error-counts-as-failure",
2195 |_fake_dir| async move {
2196 let dir = tempfile::tempdir().unwrap();
2197 let project = dir.path();
2198 write_project_with_test_file(
2199 project,
2200 r#"
2201[[test]]
2202name = "no_such_provider"
2203input = "hi"
2204expect_contains = "x"
2205"#,
2206 );
2207 std::fs::write(
2211 project.join("agent.leviath"),
2212 r#"
2213[agent]
2214name = "test-agent"
2215version = "0.1.0"
2216description = "test"
2217
2218[stages.main]
2219model = { provider = "nonexistent-provider", model = "x" }
2220"#,
2221 )
2222 .unwrap();
2223
2224 let args = TestArgs {
2225 path: Some(project.to_str().unwrap().to_string()),
2226 filter: None,
2227 dry_run: false,
2228 };
2229
2230 let result = execute_with_registry(
2231 args,
2232 Box::new(mock_registry_builder("irrelevant", vec![])),
2233 )
2234 .await;
2235 let err = result.unwrap_err().to_string();
2236 assert!(err.contains("1 test(s) failed"));
2237 },
2238 )
2239 .await;
2240 }
2241
2242 #[tokio::test]
2243 async fn execute_with_registry_non_dry_run_toml_malformed_errors() {
2244 crate::config::with_isolated_config_path_async(
2245 "test-rs-non-dry-run-toml-malformed-errors",
2246 |_fake_dir| async move {
2247 let dir = tempfile::tempdir().unwrap();
2248 let project = dir.path();
2249 write_project_with_test_file(project, "not valid {{{ toml");
2250
2251 let args = TestArgs {
2252 path: Some(project.to_str().unwrap().to_string()),
2253 filter: None,
2254 dry_run: false,
2255 };
2256
2257 let result = execute_with_registry(
2258 args,
2259 Box::new(mock_registry_builder("irrelevant", vec![])),
2260 )
2261 .await;
2262 assert!(result.is_err());
2263 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2264 },
2265 )
2266 .await;
2267 }
2268
2269 #[tokio::test]
2272 async fn execute_with_registry_rhai_script_passes() {
2273 crate::config::with_isolated_config_path_async(
2274 "test-rs-rhai-script-passes",
2275 |_fake_dir| async move {
2276 let dir = tempfile::tempdir().unwrap();
2277 let project = dir.path();
2278 let manifest = r#"
2279[agent]
2280name = "test-agent"
2281version = "0.1.0"
2282description = "test"
2283
2284[stages.main]
2285model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2286"#;
2287 write_test_agent(project, manifest);
2288 let tests_dir = project.join("tests");
2289 std::fs::create_dir_all(&tests_dir).unwrap();
2290 std::fs::write(tests_dir.join("script.rhai"), "true").unwrap();
2291
2292 let args = TestArgs {
2293 path: Some(project.to_str().unwrap().to_string()),
2294 filter: None,
2295 dry_run: false,
2296 };
2297
2298 let result =
2299 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2300 .await;
2301 assert!(result.is_ok());
2302 },
2303 )
2304 .await;
2305 }
2306
2307 #[tokio::test]
2308 async fn execute_with_registry_rhai_script_returns_false_fails() {
2309 crate::config::with_isolated_config_path_async(
2310 "test-rs-rhai-script-returns-false-fails",
2311 |_fake_dir| async move {
2312 let dir = tempfile::tempdir().unwrap();
2313 let project = dir.path();
2314 let manifest = r#"
2315[agent]
2316name = "test-agent"
2317version = "0.1.0"
2318description = "test"
2319
2320[stages.main]
2321model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2322"#;
2323 write_test_agent(project, manifest);
2324 let tests_dir = project.join("tests");
2325 std::fs::create_dir_all(&tests_dir).unwrap();
2326 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2327
2328 let args = TestArgs {
2329 path: Some(project.to_str().unwrap().to_string()),
2330 filter: None,
2331 dry_run: false,
2332 };
2333
2334 let result =
2335 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2336 .await;
2337 let err = result.unwrap_err().to_string();
2338 assert!(err.contains("1 test(s) failed"));
2339 },
2340 )
2341 .await;
2342 }
2343
2344 #[tokio::test]
2345 async fn execute_with_registry_rhai_script_error_fails() {
2346 crate::config::with_isolated_config_path_async(
2347 "test-rs-rhai-script-error-fails",
2348 |_fake_dir| async move {
2349 let dir = tempfile::tempdir().unwrap();
2350 let project = dir.path();
2351 let manifest = r#"
2352[agent]
2353name = "test-agent"
2354version = "0.1.0"
2355description = "test"
2356
2357[stages.main]
2358model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2359"#;
2360 write_test_agent(project, manifest);
2361 let tests_dir = project.join("tests");
2362 std::fs::create_dir_all(&tests_dir).unwrap();
2363 std::fs::write(tests_dir.join("script.rhai"), "this is not valid rhai (((")
2364 .unwrap();
2365
2366 let args = TestArgs {
2367 path: Some(project.to_str().unwrap().to_string()),
2368 filter: None,
2369 dry_run: false,
2370 };
2371
2372 let result =
2373 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2374 .await;
2375 assert!(result.is_err());
2376 },
2377 )
2378 .await;
2379 }
2380
2381 #[tokio::test]
2382 async fn execute_with_registry_rhai_script_non_bool_return_passes() {
2383 crate::config::with_isolated_config_path_async(
2384 "test-rs-rhai-script-non-bool-return-passes",
2385 |_fake_dir| async move {
2386 let dir = tempfile::tempdir().unwrap();
2387 let project = dir.path();
2388 let manifest = r#"
2389[agent]
2390name = "test-agent"
2391version = "0.1.0"
2392description = "test"
2393
2394[stages.main]
2395model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2396"#;
2397 write_test_agent(project, manifest);
2398 let tests_dir = project.join("tests");
2399 std::fs::create_dir_all(&tests_dir).unwrap();
2400 std::fs::write(tests_dir.join("script.rhai"), "42").unwrap();
2403
2404 let args = TestArgs {
2405 path: Some(project.to_str().unwrap().to_string()),
2406 filter: None,
2407 dry_run: false,
2408 };
2409
2410 let result =
2411 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2412 .await;
2413 assert!(result.is_ok());
2414 },
2415 )
2416 .await;
2417 }
2418
2419 #[tokio::test]
2420 async fn execute_with_registry_rhai_script_filter_excludes_all() {
2421 crate::config::with_isolated_config_path_async(
2422 "test-rs-rhai-script-filter-excludes-all",
2423 |_fake_dir| async move {
2424 let dir = tempfile::tempdir().unwrap();
2425 let project = dir.path();
2426 let manifest = r#"
2427[agent]
2428name = "test-agent"
2429version = "0.1.0"
2430description = "test"
2431
2432[stages.main]
2433model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2434"#;
2435 write_test_agent(project, manifest);
2436 let tests_dir = project.join("tests");
2437 std::fs::create_dir_all(&tests_dir).unwrap();
2438 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2439
2440 let args = TestArgs {
2441 path: Some(project.to_str().unwrap().to_string()),
2442 filter: Some("no-such-script".to_string()),
2443 dry_run: false,
2444 };
2445
2446 let result =
2449 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2450 .await;
2451 assert!(result.is_ok());
2452 },
2453 )
2454 .await;
2455 }
2456
2457 #[test]
2465 fn build_registry_from_config_registers_all_providers() {
2466 let config = Config {
2467 default_provider: "anthropic".to_string(),
2468 providers: crate::config::ProviderConfig {
2469 anthropic_api_key: Some("fake-anthropic-key".to_string()),
2470 openai_api_key: Some("fake-openai-key".to_string()),
2471 google_api_key: Some("fake-google-key".to_string()),
2472 claude_code_enabled: false,
2473 claude_code_binary: None,
2474 claude_code_effort: None,
2475 },
2476 openrouter_api_key: Some("fake-openrouter-key".to_string()),
2477 ollama_base_url: Some("http://localhost:12345".to_string()),
2478 ..Config::default()
2479 };
2480
2481 let registry = build_registry_from_config(&config);
2482 assert!(registry.has("anthropic"));
2483 assert!(registry.has("openai"));
2484 assert!(registry.has("google"));
2485 assert!(registry.has("openrouter"));
2486 assert!(registry.has("ollama"));
2487 }
2488
2489 #[test]
2490 fn build_registry_from_config_no_keys_still_registers_ollama_with_default_url() {
2491 let config = Config::default();
2492 let registry = build_registry_from_config(&config);
2493 assert!(!registry.has("anthropic"));
2494 assert!(!registry.has("openai"));
2495 assert!(!registry.has("google"));
2496 assert!(!registry.has("openrouter"));
2497 assert!(registry.has("ollama"));
2500 }
2501
2502 #[tokio::test]
2505 async fn execute_with_registry_ignores_non_test_files_in_tests_dir() {
2506 let dir = tempfile::tempdir().unwrap();
2507 let project = dir.path();
2508 let manifest = r#"
2509[agent]
2510name = "test-agent"
2511version = "0.1.0"
2512description = "test"
2513
2514[stages.main]
2515model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2516"#;
2517 write_test_agent(project, manifest);
2518 let tests_dir = project.join("tests");
2519 std::fs::create_dir_all(&tests_dir).unwrap();
2520 std::fs::write(tests_dir.join("readme.txt"), "this file should be ignored").unwrap();
2523 let args = TestArgs {
2524 path: Some(project.to_str().unwrap().to_string()),
2525 filter: None,
2526 dry_run: true,
2527 };
2528 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2529 assert!(result.is_ok());
2530 }
2531
2532 #[tokio::test]
2535 async fn error_provider_trivial_trait_methods() {
2536 let provider = ErrorProvider;
2537 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2538 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2539 assert_eq!(provider.name(), "error-provider");
2540 let caps = provider.capabilities("any-model");
2541 let _ = caps; }
2543
2544 #[tokio::test]
2547 async fn mock_provider_trivial_trait_methods() {
2548 let provider = MockProvider {
2549 content: "x".to_string(),
2550 tool_calls: vec![],
2551 };
2552 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2553 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2554 assert_eq!(provider.name(), "mock");
2555 }
2556}