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)]
17pub struct TestArgs {
18 #[arg(value_name = "PATH")]
20 pub path: Option<String>,
21
22 #[arg(short, long)]
24 pub filter: Option<String>,
25
26 #[arg(long)]
28 pub dry_run: bool,
29}
30
31#[derive(Debug, Deserialize)]
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<()> {
51 execute_with_registry(args, Box::new(build_registry_from_config)).await
52}
53
54fn build_registry_from_config(
58 config: &Config,
59) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
60 build_registry_from_config_with(config, &leviath_providers::provider::build_http_client)
61}
62
63fn build_registry_from_config_with(
66 config: &Config,
67 build_client: leviath_providers::provider::HttpClientFactory<'_>,
68) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
69 let mut reg = ProviderRegistry::new();
70 let client = build_client(None)
73 .map_err(|e| leviath_providers::ProviderError::ClientBuild(e.to_string()))?;
74
75 if let Some(ref key) = config.providers.anthropic_api_key {
76 reg.register(
77 "anthropic".to_string(),
78 Arc::new(leviath_providers::AnthropicProvider::new(
79 client.clone(),
80 key.clone(),
81 )),
82 );
83 }
84 if let Some(ref key) = config.providers.openai_api_key {
85 reg.register(
86 "openai".to_string(),
87 Arc::new(leviath_providers::OpenAIProvider::new(
88 client.clone(),
89 key.clone(),
90 )),
91 );
92 }
93 if let Some(ref key) = config.providers.google_api_key {
94 reg.register(
95 "google".to_string(),
96 Arc::new(leviath_providers::GeminiProvider::new(
97 client.clone(),
98 key.clone(),
99 )),
100 );
101 }
102 if let Some(ref key) = config.openrouter_api_key {
103 reg.register(
104 "openrouter".to_string(),
105 Arc::new(leviath_providers::OpenRouterProvider::new(
106 client.clone(),
107 key.clone(),
108 )),
109 );
110 }
111 let ollama_url = config
112 .ollama_base_url
113 .as_deref()
114 .unwrap_or("http://localhost:11434");
115 reg.register(
116 "ollama".to_string(),
117 Arc::new(leviath_providers::OllamaProvider::with_base_url(
118 client.clone(),
119 ollama_url.to_string(),
120 )),
121 );
122
123 Ok(reg)
124}
125
126type RegistryBuilder =
132 Box<dyn FnOnce(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError>>;
133
134async fn execute_with_registry(
151 args: TestArgs,
152 build_registry: RegistryBuilder,
153) -> anyhow::Result<()> {
154 let path = args.path.unwrap_or_else(|| ".".to_string());
155 tracing::info!(path = %path, "Running agent tests");
156
157 let project_path = Path::new(&path);
158
159 let manifest_path = project_path.join("agent.leviath");
161 if !manifest_path.exists() {
162 anyhow::bail!(
163 "No agent.leviath found in '{}'. Not an agent project.",
164 project_path.display()
165 );
166 }
167
168 let tests_dir = project_path.join("tests");
169 if !tests_dir.exists() {
170 println!("No tests directory found. Create tests/ with .toml or .rhai files.");
171 println!("\nExample test file (tests/basic.toml):");
172 println!(" [[test]]");
173 println!(" name = \"basic_response\"");
174 println!(" input = \"Hello\"");
175 println!(" expect_contains = \"hello\"");
176 println!("\nAlso available: expect_tool_call, max_tokens.");
181 println!("See https://leviath.dev/docs/cli#lev-test-path for what each does.");
182 return Ok(());
183 }
184
185 if args.dry_run {
186 println!("Dry run mode: validating test structure only (no API calls)\n");
187 }
188
189 let manifest_content = fs::read_to_string(&manifest_path)?;
191 let blueprint = parse_manifest(&manifest_content)?;
192
193 let region_scripts =
197 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
198 .map_err(|e| anyhow::anyhow!(e))?;
199
200 let registry = if !args.dry_run {
201 let config = Config::load()?;
202 Some(build_registry(&config)?)
203 } else {
204 None
205 };
206
207 let mut total = 0;
208 let mut passed = 0;
209 let mut failed = 0;
210 let mut failures: Vec<String> = Vec::new();
211
212 for entry in fs::read_dir(&tests_dir)?.flatten() {
214 let test_path = entry.path();
215
216 if test_path.extension().and_then(|e| e.to_str()) == Some("toml") {
217 let file_name = test_path
218 .file_name()
219 .and_then(|n| n.to_str())
220 .unwrap_or("unknown");
221
222 println!("Running test file: {}", file_name);
223
224 let content = fs::read_to_string(&test_path)?;
225 let test_file: TestFile = toml::from_str(&content)
226 .map_err(|e| anyhow::anyhow!("Failed to parse test file '{}': {}", file_name, e))?;
227
228 for test_case in &test_file.test {
229 if let Some(ref filter) = args.filter
231 && !test_case.name.contains(filter.as_str())
232 {
233 continue;
234 }
235
236 total += 1;
237
238 if args.dry_run {
239 let test_valid = validate_test_case(test_case);
241 if test_valid {
242 passed += 1;
243 println!(" PASS (dry-run): {}", test_case.name);
244 } else {
245 failed += 1;
246 let msg = format!("{}: test case validation failed", test_case.name);
247 println!(" FAIL (dry-run): {}", msg);
248 failures.push(msg);
249 }
250 } else {
251 let registry = registry
253 .as_ref()
254 .expect("registry should exist in non-dry-run");
255 match run_test_case(&blueprint, registry, test_case, ®ion_scripts).await {
256 Ok(true) => {
257 passed += 1;
258 println!(" PASS: {}", test_case.name);
259 }
260 Ok(false) => {
261 failed += 1;
262 let msg = format!("{}: assertions failed", test_case.name);
263 println!(" FAIL: {}", msg);
264 failures.push(msg);
265 }
266 Err(e) => {
267 failed += 1;
268 let msg = format!("{}: {}", test_case.name, e);
269 println!(" FAIL: {}", msg);
270 failures.push(msg);
271 }
272 }
273 }
274 }
275 } else if test_path.extension().and_then(|e| e.to_str()) == Some("rhai") {
276 let file_name = test_path
277 .file_name()
278 .and_then(|n| n.to_str())
279 .unwrap_or("unknown");
280
281 if let Some(ref filter) = args.filter
283 && !file_name.contains(filter.as_str())
284 {
285 continue;
286 }
287
288 total += 1;
289 println!("Running script: {}", file_name);
290
291 let script = fs::read_to_string(&test_path)?;
292 let engine = leviath_scripting::ScriptEngine::new();
293 let mut scope = rhai::Scope::new();
294
295 match engine.execute(&script, &mut scope) {
296 Ok(result) => {
297 if let Ok(success) = result.as_bool() {
298 if success {
299 passed += 1;
300 println!(" PASS: {}", file_name);
301 } else {
302 failed += 1;
303 let msg = format!("{}: script returned false", file_name);
304 println!(" FAIL: {}", msg);
305 failures.push(msg);
306 }
307 } else {
308 passed += 1;
309 println!(" PASS: {} (returned: {})", file_name, result);
310 }
311 }
312 Err(e) => {
313 failed += 1;
314 let msg = format!("{}: {}", file_name, e);
315 println!(" FAIL: {}", msg);
316 failures.push(msg);
317 }
318 }
319 }
320 }
321
322 println!("\n--- Results ---");
324 println!("{} passed, {} failed, {} total", passed, failed, total);
325
326 if !failures.is_empty() {
327 println!("\nFailures:");
328 for f in &failures {
329 println!(" - {}", f);
330 }
331 anyhow::bail!("{} test(s) failed", failed);
332 }
333
334 if total == 0 {
335 println!("No test files found in tests/ directory.");
336 }
337
338 Ok(())
339}
340
341fn resolved_max_tokens(case_cap: Option<usize>, ceiling: usize) -> usize {
348 match case_cap {
349 Some(cap) => cap.min(ceiling),
350 None => ceiling,
351 }
352}
353
354fn stage_tools(stage: &leviath_core::Stage) -> Vec<leviath_providers::Tool> {
360 let builtins =
363 leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(std::env::temp_dir()));
364 let mut defs = builtins.tool_defs();
365 defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
366 stage
367 .available_tools
368 .iter()
369 .filter_map(|name| defs.iter().find(|d| d.name == *name).cloned())
370 .collect()
371}
372
373async fn run_test_case(
376 blueprint: &leviath_core::Blueprint,
377 registry: &ProviderRegistry,
378 test: &TestCase,
379 region_scripts: &std::collections::HashMap<
380 String,
381 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
382 >,
383) -> anyhow::Result<bool> {
384 let stage = blueprint
386 .stages
387 .first()
388 .ok_or(anyhow::anyhow!("Blueprint has no stages"))?;
389 let provider_name = stage.model.provider();
390 let model_name = stage.model.model();
391
392 let provider = registry.get(provider_name).ok_or_else(|| {
393 anyhow::anyhow!(
394 "Provider '{}' is not configured. Set API key in ~/.leviath/config.toml",
395 provider_name
396 )
397 })?;
398
399 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
404 window.region_scripts = region_scripts.clone();
405 context_setup::init_window(&mut window, blueprint, &test.input);
406
407 let assembled = window.assemble_with_meta(&leviath_runtime::custom_region::AssembleMeta {
410 stage_name: stage.name.clone(),
411 stage_iterations: 0,
412 model: model_name.to_string(),
413 });
414 let caps = provider.capabilities(model_name);
415 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
416 let max_tokens = resolved_max_tokens(test.max_tokens, remaining.min(caps.max_output_tokens));
420 let temperature = if caps.supports_temperature { 0.7 } else { 0.0 };
421 let request = InferenceRequest {
422 system: assembled.system_blocks,
423 messages: assembled.messages,
424 model: model_name.to_string(),
425 max_tokens,
426 temperature,
427 tools: stage_tools(stage),
432 extra: serde_json::Value::Null,
433 request_timeout_secs: None,
434 };
435
436 let response = provider
437 .infer(&request)
438 .await
439 .map_err(|e| anyhow::anyhow!("Inference failed: {}", e))?;
440
441 let mut all_passed = true;
443
444 if let Some(ref expected) = test.expect_contains {
445 let content_lower = response.content.to_lowercase();
446 let expected_lower = expected.to_lowercase();
447 if !content_lower.contains(&expected_lower) {
448 println!(
449 " expect_contains failed: response does not contain '{}'",
450 expected
451 );
452 println!(" response: {}", truncate_str(&response.content, 200));
453 all_passed = false;
454 }
455 }
456
457 if let Some(ref expected_tool) = test.expect_tool_call {
458 let has_tool = response
459 .tool_calls
460 .iter()
461 .any(|tc| tc.name == *expected_tool);
462 if !has_tool {
463 println!(
464 " expect_tool_call failed: no tool call to '{}'",
465 expected_tool
466 );
467 let tool_names: Vec<&str> = response
468 .tool_calls
469 .iter()
470 .map(|tc| tc.name.as_str())
471 .collect();
472 println!(" actual tool calls: {:?}", tool_names);
473 all_passed = false;
474 }
475 }
476
477 Ok(all_passed)
478}
479
480fn validate_test_case(test: &TestCase) -> bool {
482 if test.name.is_empty() {
483 return false;
484 }
485 if test.input.is_empty() {
486 return false;
487 }
488 if test.expect_contains.is_none() && test.expect_tool_call.is_none() {
490 return false;
491 }
492 true
493}
494
495fn truncate_str(s: &str, max: usize) -> String {
500 if s.len() <= max {
501 s.to_string()
502 } else {
503 format!("{}...", truncate_at_boundary(s, max))
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::test_support::{with_tracing, write_test_agent};
511
512 #[test]
515 fn validate_test_case_valid_with_expect_contains() {
516 let tc = TestCase {
517 name: "basic".to_string(),
518 input: "hello".to_string(),
519 expect_contains: Some("world".to_string()),
520 expect_tool_call: None,
521 max_tokens: None,
522 };
523 assert!(validate_test_case(&tc));
524 }
525
526 #[test]
527 fn validate_test_case_valid_with_expect_tool_call() {
528 let tc = TestCase {
529 name: "tool_test".to_string(),
530 input: "do something".to_string(),
531 expect_contains: None,
532 expect_tool_call: Some("bash".to_string()),
533 max_tokens: None,
534 };
535 assert!(validate_test_case(&tc));
536 }
537
538 #[test]
539 fn validate_test_case_valid_with_both_assertions() {
540 let tc = TestCase {
541 name: "both".to_string(),
542 input: "test".to_string(),
543 expect_contains: Some("output".to_string()),
544 expect_tool_call: Some("read_file".to_string()),
545 max_tokens: Some(100),
546 };
547 assert!(validate_test_case(&tc));
548 }
549
550 #[test]
551 fn validate_test_case_empty_name_fails() {
552 let tc = TestCase {
553 name: String::new(),
554 input: "hello".to_string(),
555 expect_contains: Some("world".to_string()),
556 expect_tool_call: None,
557 max_tokens: None,
558 };
559 assert!(!validate_test_case(&tc));
560 }
561
562 #[test]
563 fn validate_test_case_empty_input_fails() {
564 let tc = TestCase {
565 name: "test".to_string(),
566 input: String::new(),
567 expect_contains: Some("world".to_string()),
568 expect_tool_call: None,
569 max_tokens: None,
570 };
571 assert!(!validate_test_case(&tc));
572 }
573
574 #[test]
575 fn validate_test_case_no_assertions_fails() {
576 let tc = TestCase {
577 name: "test".to_string(),
578 input: "hello".to_string(),
579 expect_contains: None,
580 expect_tool_call: None,
581 max_tokens: None,
582 };
583 assert!(!validate_test_case(&tc));
584 }
585
586 #[test]
589 fn truncate_str_short() {
590 assert_eq!(truncate_str("hello", 10), "hello");
591 }
592
593 #[test]
594 fn truncate_str_exact() {
595 assert_eq!(truncate_str("hello", 5), "hello");
596 }
597
598 #[test]
599 fn truncate_str_long() {
600 assert_eq!(truncate_str("hello world", 5), "hello...");
601 }
602
603 #[test]
604 fn truncate_str_empty() {
605 assert_eq!(truncate_str("", 5), "");
606 }
607
608 #[test]
611 fn parse_test_file_toml() {
612 let toml_content = r#"
613[[test]]
614name = "greeting"
615input = "Say hello"
616expect_contains = "hello"
617
618[[test]]
619name = "tool_use"
620input = "Read file.txt"
621expect_tool_call = "read_file"
622max_tokens = 500
623"#;
624 let test_file: TestFile = toml::from_str(toml_content).unwrap();
625 assert_eq!(test_file.test.len(), 2);
626 assert_eq!(test_file.test[0].name, "greeting");
627 assert_eq!(test_file.test[0].input, "Say hello");
628 assert_eq!(test_file.test[0].expect_contains.as_deref(), Some("hello"));
629 assert!(test_file.test[0].expect_tool_call.is_none());
630 assert!(test_file.test[0].max_tokens.is_none());
631
632 assert_eq!(test_file.test[1].name, "tool_use");
633 assert_eq!(
634 test_file.test[1].expect_tool_call.as_deref(),
635 Some("read_file")
636 );
637 assert_eq!(test_file.test[1].max_tokens, Some(500));
638 }
639
640 fn test_model() -> leviath_core::blueprint::ModelConfig {
643 leviath_core::blueprint::ModelConfig::new("anthropic".to_string(), "m".to_string())
644 }
645
646 #[test]
650 fn a_case_max_tokens_narrows_the_ceiling_and_never_widens_it() {
651 let ceiling = 4_000;
652 assert_eq!(
653 resolved_max_tokens(Some(500), ceiling),
654 500,
655 "a smaller case cap wins"
656 );
657 assert_eq!(
658 resolved_max_tokens(Some(99_000), ceiling),
659 ceiling,
660 "a case may not ask for more than the model allows"
661 );
662 assert_eq!(
663 resolved_max_tokens(None, ceiling),
664 ceiling,
665 "no cap means the full ceiling"
666 );
667 }
668
669 #[test]
673 fn a_stage_advertises_its_tools_so_a_tool_call_is_possible() {
674 let mut stage = leviath_core::Stage::new("s".to_string(), test_model());
675 stage.available_tools = vec!["read_file".to_string(), "write_file".to_string()];
676 let tools = stage_tools(&stage);
677 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
678 assert!(names.contains(&"read_file"), "got {names:?}");
679 assert!(names.contains(&"write_file"), "got {names:?}");
680 }
681
682 #[test]
685 fn a_stage_with_no_tools_advertises_none() {
686 let stage = leviath_core::Stage::new("s".to_string(), test_model());
687 assert!(stage_tools(&stage).is_empty());
688 }
689
690 #[test]
693 fn an_unknown_tool_name_is_not_advertised() {
694 let mut stage = leviath_core::Stage::new("s".to_string(), test_model());
695 stage.available_tools = vec!["definitely_not_a_tool".to_string()];
696 assert!(stage_tools(&stage).is_empty());
697 }
698
699 #[test]
700 fn parse_test_file_minimal() {
701 let toml_content = r#"
702[[test]]
703name = "min"
704input = "test"
705expect_contains = "ok"
706"#;
707 let test_file: TestFile = toml::from_str(toml_content).unwrap();
708 assert_eq!(test_file.test.len(), 1);
709 }
710
711 #[test]
712 fn parse_test_file_invalid_toml_errors() {
713 let result: Result<TestFile, _> = toml::from_str("not valid toml {{{{");
714 assert!(result.is_err());
715 }
716
717 #[tokio::test]
720 async fn dry_run_with_temp_project() {
721 let dir = tempfile::tempdir().unwrap();
722 let project = dir.path();
723
724 let manifest = r#"
726[agent]
727name = "test-agent"
728version = "0.1.0"
729description = "test"
730
731[stages.main]
732model = { provider = "anthropic", model = "claude-sonnet-4-6" }
733"#;
734 write_test_agent(project, manifest);
735
736 let tests_dir = project.join("tests");
738 std::fs::create_dir_all(&tests_dir).unwrap();
739 let test_toml = r#"
740[[test]]
741name = "valid_test"
742input = "hello"
743expect_contains = "world"
744"#;
745 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
746
747 let args = TestArgs {
748 path: Some(project.to_str().unwrap().to_string()),
749 filter: None,
750 dry_run: true,
751 };
752
753 let result = execute(args).await;
754 assert!(result.is_ok());
755 }
756
757 #[tokio::test]
758 async fn dry_run_no_tests_dir() {
759 let dir = tempfile::tempdir().unwrap();
760 let project = dir.path();
761
762 let manifest = r#"
763[agent]
764name = "test-agent"
765version = "0.1.0"
766description = "test"
767
768[stages.main]
769model = { provider = "anthropic", model = "claude-sonnet-4-6" }
770"#;
771 write_test_agent(project, manifest);
772
773 let args = TestArgs {
774 path: Some(project.to_str().unwrap().to_string()),
775 filter: None,
776 dry_run: true,
777 };
778
779 let result = execute(args).await;
781 assert!(result.is_ok());
782 }
783
784 #[tokio::test]
785 async fn execute_no_manifest_errors() {
786 let dir = tempfile::tempdir().unwrap();
787 let args = TestArgs {
788 path: Some(dir.path().to_str().unwrap().to_string()),
789 filter: None,
790 dry_run: true,
791 };
792 let result = execute(args).await;
793 assert!(result.is_err());
794 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
795 }
796
797 #[test]
800 fn test_case_all_fields_from_toml() {
801 let toml_content = r#"
802[[test]]
803name = "full_test"
804input = "full input"
805expect_contains = "expected"
806expect_tool_call = "bash"
807max_tokens = 1000
808"#;
809 let test_file: TestFile = toml::from_str(toml_content).unwrap();
810 let tc = &test_file.test[0];
811 assert_eq!(tc.name, "full_test");
812 assert_eq!(tc.input, "full input");
813 assert_eq!(tc.expect_contains.as_deref(), Some("expected"));
814 assert_eq!(tc.expect_tool_call.as_deref(), Some("bash"));
815 assert_eq!(tc.max_tokens, Some(1000));
816 }
817
818 #[test]
819 fn test_case_minimal_from_toml() {
820 let toml_content = r#"
821[[test]]
822name = "min"
823input = "hello"
824expect_contains = "world"
825"#;
826 let test_file: TestFile = toml::from_str(toml_content).unwrap();
827 let tc = &test_file.test[0];
828 assert!(tc.expect_tool_call.is_none());
829 assert!(tc.max_tokens.is_none());
830 }
831
832 #[test]
833 fn test_file_multiple_cases() {
834 let toml_content = r#"
835[[test]]
836name = "case1"
837input = "a"
838expect_contains = "b"
839
840[[test]]
841name = "case2"
842input = "c"
843expect_tool_call = "read_file"
844
845[[test]]
846name = "case3"
847input = "d"
848expect_contains = "e"
849expect_tool_call = "bash"
850max_tokens = 500
851"#;
852 let test_file: TestFile = toml::from_str(toml_content).unwrap();
853 assert_eq!(test_file.test.len(), 3);
854 }
855
856 #[test]
859 fn validate_test_case_whitespace_name_passes() {
860 let tc = TestCase {
862 name: " ".to_string(),
863 input: "hello".to_string(),
864 expect_contains: Some("world".to_string()),
865 expect_tool_call: None,
866 max_tokens: None,
867 };
868 assert!(validate_test_case(&tc));
869 }
870
871 #[test]
874 fn truncate_str_one_char_max() {
875 assert_eq!(truncate_str("hello", 1), "h...");
876 }
877
878 #[test]
879 fn truncate_str_unicode() {
880 assert_eq!(truncate_str("abcde", 3), "abc...");
881 assert_eq!(truncate_str("abc🎉def", 4), "abc...");
885 assert_eq!(truncate_str("abc🎉def", 6), "abc...");
886 assert_eq!(truncate_str("abc🎉def", 7), "abc🎉...");
888 assert_eq!(truncate_str("🎉🎉", 2), "...");
891 }
892
893 #[tokio::test]
896 async fn dry_run_with_filter_matches() {
897 let dir = tempfile::tempdir().unwrap();
898 let project = dir.path();
899 let manifest = r#"
900[agent]
901name = "test-agent"
902version = "0.1.0"
903description = "test"
904
905[stages.main]
906model = { provider = "anthropic", model = "claude-sonnet-4-6" }
907"#;
908 write_test_agent(project, manifest);
909 let tests_dir = project.join("tests");
910 std::fs::create_dir_all(&tests_dir).unwrap();
911 let test_toml = r#"
912[[test]]
913name = "alpha_test"
914input = "hello"
915expect_contains = "world"
916
917[[test]]
918name = "beta_test"
919input = "hello"
920expect_contains = "world"
921"#;
922 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
923
924 let args = TestArgs {
925 path: Some(project.to_str().unwrap().to_string()),
926 filter: Some("alpha".to_string()),
927 dry_run: true,
928 };
929 let result = execute(args).await;
930 assert!(result.is_ok());
931 }
932
933 #[tokio::test]
934 async fn dry_run_failing_test_case() {
935 let dir = tempfile::tempdir().unwrap();
936 let project = dir.path();
937 let manifest = r#"
938[agent]
939name = "test-agent"
940version = "0.1.0"
941description = "test"
942
943[stages.main]
944model = { provider = "anthropic", model = "claude-sonnet-4-6" }
945"#;
946 write_test_agent(project, manifest);
947 let tests_dir = project.join("tests");
948 std::fs::create_dir_all(&tests_dir).unwrap();
949 let test_toml = r#"
951[[test]]
952name = "bad_test"
953input = "hello"
954"#;
955 std::fs::write(tests_dir.join("fail.toml"), test_toml).unwrap();
956
957 let args = TestArgs {
958 path: Some(project.to_str().unwrap().to_string()),
959 filter: None,
960 dry_run: true,
961 };
962 let result = execute(args).await;
963 assert!(result.is_err()); }
965
966 #[test]
969 fn validate_test_case_with_max_tokens_only_and_no_assertion_fails() {
970 let tc = TestCase {
971 name: "has-max-tokens".to_string(),
972 input: "test".to_string(),
973 expect_contains: None,
974 expect_tool_call: None,
975 max_tokens: Some(500),
976 };
977 assert!(!validate_test_case(&tc));
978 }
979
980 #[test]
981 fn validate_test_case_with_only_tool_call_assertion() {
982 let tc = TestCase {
983 name: "tool-only".to_string(),
984 input: "do it".to_string(),
985 expect_contains: None,
986 expect_tool_call: Some("write_file".to_string()),
987 max_tokens: None,
988 };
989 assert!(validate_test_case(&tc));
990 }
991
992 #[test]
995 fn truncate_str_zero_max() {
996 assert_eq!(truncate_str("hello", 0), "...");
997 }
998
999 #[test]
1000 fn truncate_str_large_max() {
1001 let s = "short";
1002 assert_eq!(truncate_str(s, 1000), "short");
1003 }
1004
1005 #[test]
1008 fn parse_test_file_empty_tests_array() {
1009 let toml_content = r#"
1010test = []
1011"#;
1012 let test_file: TestFile = toml::from_str(toml_content).unwrap();
1013 assert!(test_file.test.is_empty());
1014 }
1015
1016 #[test]
1017 fn parse_test_file_missing_test_key_errors() {
1018 let result: Result<TestFile, _> = toml::from_str("something_else = 42");
1019 assert!(result.is_err());
1020 }
1021
1022 #[tokio::test]
1025 async fn dry_run_with_filter_no_match() {
1026 let dir = tempfile::tempdir().unwrap();
1027 let project = dir.path();
1028 let manifest = r#"
1029[agent]
1030name = "test-agent"
1031version = "0.1.0"
1032description = "test"
1033
1034[stages.main]
1035model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1036"#;
1037 write_test_agent(project, manifest);
1038 let tests_dir = project.join("tests");
1039 std::fs::create_dir_all(&tests_dir).unwrap();
1040 let test_toml = r#"
1041[[test]]
1042name = "alpha_test"
1043input = "hello"
1044expect_contains = "world"
1045"#;
1046 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
1047
1048 let args = TestArgs {
1049 path: Some(project.to_str().unwrap().to_string()),
1050 filter: Some("nonexistent_filter".to_string()),
1051 dry_run: true,
1052 };
1053 let result = execute(args).await;
1055 assert!(result.is_ok());
1056 }
1057
1058 #[tokio::test]
1061 async fn dry_run_with_rhai_script_passing() {
1062 let dir = tempfile::tempdir().unwrap();
1063 let project = dir.path();
1064 let manifest = r#"
1065[agent]
1066name = "test-agent"
1067version = "0.1.0"
1068description = "test"
1069
1070[stages.main]
1071model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1072"#;
1073 write_test_agent(project, manifest);
1074 let tests_dir = project.join("tests");
1075 std::fs::create_dir_all(&tests_dir).unwrap();
1076
1077 std::fs::write(tests_dir.join("pass_test.rhai"), "true").unwrap();
1079
1080 let args = TestArgs {
1081 path: Some(project.to_str().unwrap().to_string()),
1082 filter: None,
1083 dry_run: true,
1084 };
1085 let result = execute(args).await;
1086 assert!(result.is_ok());
1087 }
1088
1089 #[tokio::test]
1090 async fn dry_run_with_rhai_script_returning_false() {
1091 let dir = tempfile::tempdir().unwrap();
1092 let project = dir.path();
1093 let manifest = r#"
1094[agent]
1095name = "test-agent"
1096version = "0.1.0"
1097description = "test"
1098
1099[stages.main]
1100model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1101"#;
1102 write_test_agent(project, manifest);
1103 let tests_dir = project.join("tests");
1104 std::fs::create_dir_all(&tests_dir).unwrap();
1105
1106 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1108
1109 let args = TestArgs {
1110 path: Some(project.to_str().unwrap().to_string()),
1111 filter: None,
1112 dry_run: true,
1113 };
1114 let result = execute(args).await;
1115 assert!(result.is_err()); }
1117
1118 #[tokio::test]
1119 async fn dry_run_with_rhai_script_error() {
1120 let dir = tempfile::tempdir().unwrap();
1121 let project = dir.path();
1122 let manifest = r#"
1123[agent]
1124name = "test-agent"
1125version = "0.1.0"
1126description = "test"
1127
1128[stages.main]
1129model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1130"#;
1131 write_test_agent(project, manifest);
1132 let tests_dir = project.join("tests");
1133 std::fs::create_dir_all(&tests_dir).unwrap();
1134
1135 std::fs::write(
1137 tests_dir.join("error_test.rhai"),
1138 "throw \"intentional error\"",
1139 )
1140 .unwrap();
1141
1142 let args = TestArgs {
1143 path: Some(project.to_str().unwrap().to_string()),
1144 filter: None,
1145 dry_run: true,
1146 };
1147 let result = execute(args).await;
1148 assert!(result.is_err()); }
1150
1151 #[tokio::test]
1152 async fn dry_run_with_rhai_non_bool_result_passes() {
1153 let dir = tempfile::tempdir().unwrap();
1154 let project = dir.path();
1155 let manifest = r#"
1156[agent]
1157name = "test-agent"
1158version = "0.1.0"
1159description = "test"
1160
1161[stages.main]
1162model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1163"#;
1164 write_test_agent(project, manifest);
1165 let tests_dir = project.join("tests");
1166 std::fs::create_dir_all(&tests_dir).unwrap();
1167
1168 std::fs::write(tests_dir.join("nonbool_test.rhai"), "42").unwrap();
1170
1171 let args = TestArgs {
1172 path: Some(project.to_str().unwrap().to_string()),
1173 filter: None,
1174 dry_run: true,
1175 };
1176 let result = execute(args).await;
1177 assert!(result.is_ok()); }
1179
1180 #[tokio::test]
1181 async fn dry_run_with_rhai_filter_matches() {
1182 let dir = tempfile::tempdir().unwrap();
1183 let project = dir.path();
1184 let manifest = r#"
1185[agent]
1186name = "test-agent"
1187version = "0.1.0"
1188description = "test"
1189
1190[stages.main]
1191model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1192"#;
1193 write_test_agent(project, manifest);
1194 let tests_dir = project.join("tests");
1195 std::fs::create_dir_all(&tests_dir).unwrap();
1196
1197 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1199 std::fs::write(tests_dir.join("good_test.rhai"), "true").unwrap();
1201
1202 let args = TestArgs {
1203 path: Some(project.to_str().unwrap().to_string()),
1204 filter: Some("good".to_string()),
1205 dry_run: true,
1206 };
1207 let result = execute(args).await;
1208 assert!(result.is_ok()); }
1210
1211 #[tokio::test]
1214 async fn dry_run_with_multiple_test_files() {
1215 let dir = tempfile::tempdir().unwrap();
1216 let project = dir.path();
1217 let manifest = r#"
1218[agent]
1219name = "test-agent"
1220version = "0.1.0"
1221description = "test"
1222
1223[stages.main]
1224model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1225"#;
1226 write_test_agent(project, manifest);
1227 let tests_dir = project.join("tests");
1228 std::fs::create_dir_all(&tests_dir).unwrap();
1229
1230 let test1 = r#"
1231[[test]]
1232name = "test_a"
1233input = "hello"
1234expect_contains = "world"
1235"#;
1236 let test2 = r#"
1237[[test]]
1238name = "test_b"
1239input = "foo"
1240expect_tool_call = "bar"
1241"#;
1242 std::fs::write(tests_dir.join("file1.toml"), test1).unwrap();
1243 std::fs::write(tests_dir.join("file2.toml"), test2).unwrap();
1244
1245 let args = TestArgs {
1246 path: Some(project.to_str().unwrap().to_string()),
1247 filter: None,
1248 dry_run: true,
1249 };
1250 let result = execute(args).await;
1251 assert!(result.is_ok());
1252 }
1253
1254 #[tokio::test]
1257 async fn dry_run_with_invalid_toml_file() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let project = dir.path();
1260 let manifest = r#"
1261[agent]
1262name = "test-agent"
1263version = "0.1.0"
1264description = "test"
1265
1266[stages.main]
1267model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1268"#;
1269 write_test_agent(project, manifest);
1270 let tests_dir = project.join("tests");
1271 std::fs::create_dir_all(&tests_dir).unwrap();
1272 std::fs::write(tests_dir.join("bad.toml"), "not valid {{{ toml").unwrap();
1273
1274 let args = TestArgs {
1275 path: Some(project.to_str().unwrap().to_string()),
1276 filter: None,
1277 dry_run: true,
1278 };
1279 let result = execute(args).await;
1280 assert!(result.is_err());
1281 }
1282
1283 use leviath_providers::{
1297 FinishReason, InferenceRequest, InferenceResponse, Provider, TokenUsage, ToolCall,
1298 };
1299
1300 struct MockProvider {
1303 content: String,
1304 tool_calls: Vec<ToolCall>,
1305 }
1306
1307 #[async_trait::async_trait]
1308 impl Provider for MockProvider {
1309 async fn infer(
1310 &self,
1311 _request: &InferenceRequest,
1312 ) -> leviath_providers::Result<InferenceResponse> {
1313 Ok(InferenceResponse {
1314 content: self.content.clone(),
1315 tool_calls: self.tool_calls.clone(),
1316 tokens_used: TokenUsage {
1317 prompt_tokens: 1,
1318 completion_tokens: 1,
1319 total_tokens: 2,
1320 cached_tokens: 0,
1321 cache_write_tokens: 0,
1322 },
1323 finish_reason: FinishReason::Complete,
1324 })
1325 }
1326
1327 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1328 text.len()
1329 }
1330
1331 fn max_context_tokens(&self, _model: &str) -> usize {
1332 8192
1333 }
1334
1335 fn name(&self) -> &str {
1336 "mock"
1337 }
1338
1339 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1340 leviath_providers::ModelCapabilities::default()
1341 }
1342 }
1343
1344 struct NoTemperatureProvider;
1347
1348 #[async_trait::async_trait]
1349 impl Provider for NoTemperatureProvider {
1350 async fn infer(
1351 &self,
1352 _request: &InferenceRequest,
1353 ) -> leviath_providers::Result<InferenceResponse> {
1354 Ok(InferenceResponse {
1355 content: "cold hello".to_string(),
1356 tool_calls: vec![],
1357 tokens_used: TokenUsage {
1358 prompt_tokens: 1,
1359 completion_tokens: 1,
1360 total_tokens: 2,
1361 cached_tokens: 0,
1362 cache_write_tokens: 0,
1363 },
1364 finish_reason: FinishReason::Complete,
1365 })
1366 }
1367
1368 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1369 text.len()
1370 }
1371
1372 fn max_context_tokens(&self, _model: &str) -> usize {
1373 8192
1374 }
1375
1376 fn name(&self) -> &str {
1377 "no-temperature"
1378 }
1379
1380 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1381 leviath_providers::ModelCapabilities {
1382 supports_temperature: false,
1383 ..Default::default()
1384 }
1385 }
1386 }
1387
1388 struct ErrorProvider;
1390
1391 #[async_trait::async_trait]
1392 impl Provider for ErrorProvider {
1393 async fn infer(
1394 &self,
1395 _request: &InferenceRequest,
1396 ) -> leviath_providers::Result<InferenceResponse> {
1397 Err(leviath_providers::ProviderError::ApiError(
1398 "simulated inference error".to_string(),
1399 ))
1400 }
1401
1402 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1403 text.len()
1404 }
1405
1406 fn max_context_tokens(&self, _model: &str) -> usize {
1407 8192
1408 }
1409
1410 fn name(&self) -> &str {
1411 "error-provider"
1412 }
1413
1414 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1415 leviath_providers::ModelCapabilities::default()
1416 }
1417 }
1418
1419 fn basic_blueprint() -> leviath_core::Blueprint {
1420 let manifest = r#"
1421[agent]
1422name = "test-agent"
1423version = "0.1.0"
1424description = "test"
1425
1426[stages.main]
1427model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1428"#;
1429 parse_manifest(manifest).unwrap()
1430 }
1431
1432 fn blueprint_with_tool_results_region() -> leviath_core::Blueprint {
1435 let manifest = r#"
1436[agent]
1437name = "test-agent"
1438version = "0.1.0"
1439description = "test"
1440
1441[stages.main]
1442model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1443
1444[context.regions.tool_results]
1445kind = "temporary"
1446max_tokens = 5000
1447"#;
1448 parse_manifest(manifest).unwrap()
1449 }
1450
1451 struct RecordingProvider {
1455 seen: std::sync::Arc<std::sync::Mutex<Option<InferenceRequest>>>,
1456 }
1457
1458 #[async_trait::async_trait]
1459 impl Provider for RecordingProvider {
1460 async fn infer(
1461 &self,
1462 request: &InferenceRequest,
1463 ) -> leviath_providers::Result<InferenceResponse> {
1464 *self.seen.lock().unwrap() = Some(request.clone());
1465 Ok(InferenceResponse {
1466 content: "recorded".to_string(),
1467 tool_calls: vec![],
1468 tokens_used: TokenUsage {
1469 prompt_tokens: 1,
1470 completion_tokens: 1,
1471 total_tokens: 2,
1472 cached_tokens: 0,
1473 cache_write_tokens: 0,
1474 },
1475 finish_reason: FinishReason::Complete,
1476 })
1477 }
1478
1479 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1480 text.len()
1481 }
1482
1483 fn max_context_tokens(&self, _model: &str) -> usize {
1484 8192
1485 }
1486
1487 fn name(&self) -> &str {
1488 "recording"
1489 }
1490
1491 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1492 leviath_providers::ModelCapabilities::default()
1493 }
1494 }
1495
1496 #[tokio::test]
1499 async fn run_test_case_renders_custom_region_through_its_script() {
1500 let manifest = r#"
1501[agent]
1502name = "custom-test-agent"
1503version = "0.1.0"
1504description = "test"
1505
1506[stages.main]
1507model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1508
1509[context.regions.task]
1510kind = "pinned"
1511max_tokens = 4000
1512
1513[context.regions.brain]
1514kind = "custom"
1515script = "hooks/brain.rhai"
1516max_tokens = 4000
1517"#;
1518 let blueprint = parse_manifest(manifest).unwrap();
1519 let scripts = std::collections::HashMap::from([(
1520 "hooks/brain.rhai".to_string(),
1521 std::sync::Arc::new(
1522 leviath_scripting::region_hook::compile(
1523 "hooks/brain.rhai",
1524 "fn render(ctx) { `<brain stage=${ctx.stage_name} model=${ctx.model}>` }",
1525 )
1526 .unwrap(),
1527 ),
1528 )]);
1529 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1530 let mut registry = ProviderRegistry::new();
1531 registry.register(
1532 "anthropic".to_string(),
1533 Arc::new(RecordingProvider { seen: seen.clone() }),
1534 );
1535 let tc = TestCase {
1536 name: "custom_render".to_string(),
1537 input: "hi".to_string(),
1538 expect_contains: Some("recorded".to_string()),
1539 expect_tool_call: None,
1540 max_tokens: None,
1541 };
1542 let passed = run_test_case(&blueprint, ®istry, &tc, &scripts)
1543 .await
1544 .unwrap();
1545 assert!(passed);
1546 let request = seen.lock().unwrap().take().expect("provider saw a request");
1547 let system_texts: Vec<&String> = request.system.iter().map(|b| &b.text).collect();
1549 let rendered = system_texts
1550 .iter()
1551 .any(|t| t.as_str() == "<brain stage=main model=claude-sonnet-4-6>");
1552 assert!(
1553 rendered,
1554 "custom region rendered with stage metadata; system blocks: {system_texts:?}"
1555 );
1556
1557 let provider = registry.get("anthropic").unwrap();
1559 assert_eq!(provider.count_tokens("abcd", "m").await, 4);
1560 assert_eq!(provider.max_context_tokens("m"), 8192);
1561 assert_eq!(provider.name(), "recording");
1562 }
1563
1564 #[tokio::test]
1567 async fn execute_fails_fast_on_a_broken_custom_region_script() {
1568 let dir = tempfile::tempdir().unwrap();
1569 let project = dir.path();
1570 std::fs::write(
1571 project.join("agent.leviath"),
1572 r#"
1573[agent]
1574name = "broken-custom"
1575version = "0.1.0"
1576description = "d"
1577
1578[stages.main]
1579model = { provider = "anthropic", model = "m" }
1580
1581[context.regions.brain]
1582kind = "custom"
1583script = "hooks/missing.rhai"
1584max_tokens = 4000
1585"#,
1586 )
1587 .unwrap();
1588 std::fs::create_dir(project.join("tests")).unwrap();
1589 std::fs::write(
1590 project.join("tests/basic.toml"),
1591 "[[test]]\nname = \"t\"\ninput = \"hi\"\n",
1592 )
1593 .unwrap();
1594 let args = TestArgs {
1595 path: Some(project.to_str().unwrap().to_string()),
1596 filter: None,
1597 dry_run: true,
1598 };
1599 let err = execute(args).await.unwrap_err().to_string();
1600 assert!(err.contains("region 'brain'"), "{err}");
1601 assert!(err.contains("hooks/missing.rhai"), "{err}");
1602 }
1603
1604 #[tokio::test]
1609 async fn run_test_case_inference_error_propagates() {
1610 let blueprint = basic_blueprint();
1611 let mut registry = ProviderRegistry::new();
1612 registry.register("anthropic".to_string(), Arc::new(ErrorProvider));
1613 let tc = TestCase {
1614 name: "inference_error".to_string(),
1615 input: "hi".to_string(),
1616 expect_contains: Some("x".to_string()),
1617 expect_tool_call: None,
1618 max_tokens: None,
1619 };
1620 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1621 let err = result.unwrap_err().to_string();
1622 assert!(err.contains("Inference failed"));
1623 }
1624
1625 #[tokio::test]
1627 async fn run_test_case_blueprint_with_no_stages_errors() {
1628 use leviath_core::{Blueprint, layout::ContextLayout};
1629 let blueprint = Blueprint::new(
1630 "no-stages".to_string(),
1631 "test".to_string(),
1632 vec![],
1633 ContextLayout::new(vec![], 4096),
1634 );
1635 let registry = ProviderRegistry::new();
1636 let tc = TestCase {
1637 name: "no_stages".to_string(),
1638 input: "hi".to_string(),
1639 expect_contains: Some("x".to_string()),
1640 expect_tool_call: None,
1641 max_tokens: None,
1642 };
1643 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1644 let err = result.unwrap_err().to_string();
1645 assert!(err.contains("Blueprint has no stages"));
1646 }
1647
1648 #[tokio::test]
1651 async fn run_test_case_with_preexisting_tool_results_region() {
1652 let blueprint = blueprint_with_tool_results_region();
1653 let mut registry = ProviderRegistry::new();
1654 registry.register(
1655 "anthropic".to_string(),
1656 Arc::new(MockProvider {
1657 content: "hello world".to_string(),
1658 tool_calls: vec![],
1659 }),
1660 );
1661 let tc = TestCase {
1662 name: "has_tool_results_region".to_string(),
1663 input: "hi".to_string(),
1664 expect_contains: Some("world".to_string()),
1665 expect_tool_call: None,
1666 max_tokens: None,
1667 };
1668 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1669 assert!(result.unwrap());
1670 }
1671
1672 #[tokio::test]
1676 async fn execute_with_registry_manifest_unreadable_errors() {
1677 let dir = tempfile::tempdir().unwrap();
1678 let project = dir.path();
1679 std::fs::create_dir_all(project.join("agent.leviath")).unwrap();
1680 let tests_dir = project.join("tests");
1681 std::fs::create_dir_all(&tests_dir).unwrap();
1682 let args = TestArgs {
1683 path: Some(project.to_str().unwrap().to_string()),
1684 filter: None,
1685 dry_run: true,
1686 };
1687 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1688 assert!(result.is_err());
1689 }
1690
1691 #[tokio::test]
1696 async fn execute_with_registry_config_load_fails_errors() {
1697 let dir = tempfile::tempdir().unwrap();
1698 let project = dir.path();
1699 let manifest = r#"
1700[agent]
1701name = "test-agent"
1702version = "0.1.0"
1703description = "test"
1704
1705[stages.main]
1706model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1707"#;
1708 write_test_agent(project, manifest);
1709 let tests_dir = project.join("tests");
1710 std::fs::create_dir_all(&tests_dir).unwrap();
1711
1712 crate::config::with_isolated_config_path_async(
1714 "test-cmd-config-fail",
1715 |fake_dir| async move {
1716 let bad_config = fake_dir.join("config.toml");
1717 std::fs::write(&bad_config, "not valid toml {{{").unwrap();
1718
1719 let args = TestArgs {
1720 path: Some(project.to_str().unwrap().to_string()),
1721 filter: None,
1722 dry_run: false, };
1724 let result =
1725 execute_with_registry(args, Box::new(build_registry_from_config)).await;
1726 assert!(result.is_err());
1727 },
1728 )
1729 .await;
1730 }
1731
1732 #[tokio::test]
1735 async fn execute_with_registry_manifest_invalid_toml_errors() {
1736 let dir = tempfile::tempdir().unwrap();
1737 let project = dir.path();
1738 std::fs::write(project.join("agent.leviath"), "not valid toml {{{").unwrap();
1739 let tests_dir = project.join("tests");
1740 std::fs::create_dir_all(&tests_dir).unwrap();
1741 let args = TestArgs {
1742 path: Some(project.to_str().unwrap().to_string()),
1743 filter: None,
1744 dry_run: false,
1745 };
1746 let result =
1747 execute_with_registry(args, Box::new(mock_registry_builder("irrelevant", vec![])))
1748 .await;
1749 assert!(result.is_err());
1750 }
1751
1752 #[tokio::test]
1755 async fn execute_with_registry_tests_dir_unreadable_errors() {
1756 let dir = tempfile::tempdir().unwrap();
1757 let project = dir.path();
1758 let manifest = r#"
1759[agent]
1760name = "test-agent"
1761version = "0.1.0"
1762description = "test"
1763
1764[stages.main]
1765model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1766"#;
1767 write_test_agent(project, manifest);
1768 std::fs::write(project.join("tests"), "not a dir").unwrap();
1770 let args = TestArgs {
1771 path: Some(project.to_str().unwrap().to_string()),
1772 filter: None,
1773 dry_run: true,
1774 };
1775 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1776 assert!(result.is_err());
1777 }
1778
1779 #[tokio::test]
1783 async fn execute_with_registry_toml_unreadable_errors() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let project = dir.path();
1786 let manifest = r#"
1787[agent]
1788name = "test-agent"
1789version = "0.1.0"
1790description = "test"
1791
1792[stages.main]
1793model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1794"#;
1795 write_test_agent(project, manifest);
1796 let tests_dir = project.join("tests");
1797 std::fs::create_dir_all(&tests_dir).unwrap();
1798 std::fs::create_dir_all(tests_dir.join("unreadable.toml")).unwrap();
1799 let args = TestArgs {
1800 path: Some(project.to_str().unwrap().to_string()),
1801 filter: None,
1802 dry_run: true,
1803 };
1804 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1805 assert!(result.is_err());
1806 }
1807
1808 #[tokio::test]
1812 async fn execute_with_registry_rhai_unreadable_errors() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let project = dir.path();
1815 let manifest = r#"
1816[agent]
1817name = "test-agent"
1818version = "0.1.0"
1819description = "test"
1820
1821[stages.main]
1822model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1823"#;
1824 write_test_agent(project, manifest);
1825 let tests_dir = project.join("tests");
1826 std::fs::create_dir_all(&tests_dir).unwrap();
1827 std::fs::create_dir_all(tests_dir.join("unreadable.rhai")).unwrap();
1828 let args = TestArgs {
1829 path: Some(project.to_str().unwrap().to_string()),
1830 filter: None,
1831 dry_run: true,
1832 };
1833 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1834 assert!(result.is_err());
1835 }
1836
1837 #[tokio::test]
1840 async fn run_test_case_with_no_pinned_region_still_runs() {
1841 use leviath_core::Blueprint;
1842 use leviath_core::layout::ContextLayout;
1843 let blueprint = Blueprint::new(
1844 "no-regions".to_string(),
1845 "test".to_string(),
1846 vec![leviath_core::Stage::new(
1847 "main".to_string(),
1848 leviath_core::blueprint::ModelConfig::new(
1849 "anthropic".to_string(),
1850 "claude-sonnet-4-6".to_string(),
1851 ),
1852 )],
1853 ContextLayout::new(vec![], 4096),
1854 );
1855 let mut registry = ProviderRegistry::new();
1856 registry.register(
1857 "anthropic".to_string(),
1858 Arc::new(MockProvider {
1859 content: "hello world".to_string(),
1860 tool_calls: vec![],
1861 }),
1862 );
1863 let tc = TestCase {
1864 name: "no_pinned".to_string(),
1865 input: "hi".to_string(),
1866 expect_contains: Some("world".to_string()),
1867 expect_tool_call: None,
1868 max_tokens: None,
1869 };
1870 assert!(
1871 run_test_case(&blueprint, ®istry, &tc, &Default::default())
1872 .await
1873 .unwrap()
1874 );
1875 }
1876
1877 #[tokio::test]
1878 async fn run_test_case_passes_with_expect_contains() {
1879 let blueprint = basic_blueprint();
1880 let mut registry = ProviderRegistry::new();
1881 registry.register(
1882 "anthropic".to_string(),
1883 Arc::new(MockProvider {
1884 content: "Hello, world!".to_string(),
1885 tool_calls: vec![],
1886 }),
1887 );
1888
1889 let tc = TestCase {
1890 name: "greeting".to_string(),
1891 input: "say hello".to_string(),
1892 expect_contains: Some("world".to_string()),
1893 expect_tool_call: None,
1894 max_tokens: None,
1895 };
1896
1897 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1898 assert!(result.unwrap());
1899 }
1900
1901 #[tokio::test]
1902 async fn run_test_case_fails_expect_contains_mismatch() {
1903 let blueprint = basic_blueprint();
1904 let mut registry = ProviderRegistry::new();
1905 registry.register(
1906 "anthropic".to_string(),
1907 Arc::new(MockProvider {
1908 content: "Goodbye".to_string(),
1909 tool_calls: vec![],
1910 }),
1911 );
1912
1913 let tc = TestCase {
1914 name: "greeting".to_string(),
1915 input: "say hello".to_string(),
1916 expect_contains: Some("world".to_string()),
1917 expect_tool_call: None,
1918 max_tokens: None,
1919 };
1920
1921 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1922 assert!(!result.unwrap());
1923 }
1924
1925 #[tokio::test]
1926 async fn run_test_case_passes_with_expect_tool_call() {
1927 let blueprint = basic_blueprint();
1928 let mut registry = ProviderRegistry::new();
1929 registry.register(
1930 "anthropic".to_string(),
1931 Arc::new(MockProvider {
1932 content: String::new(),
1933 tool_calls: vec![ToolCall {
1934 id: "call_1".to_string(),
1935 name: "bash".to_string(),
1936 arguments: serde_json::json!({}),
1937 thought_signature: None,
1938 }],
1939 }),
1940 );
1941
1942 let tc = TestCase {
1943 name: "tool_test".to_string(),
1944 input: "run a command".to_string(),
1945 expect_contains: None,
1946 expect_tool_call: Some("bash".to_string()),
1947 max_tokens: None,
1948 };
1949
1950 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1951 assert!(result.unwrap());
1952 }
1953
1954 #[tokio::test]
1955 async fn run_test_case_fails_expect_tool_call_missing() {
1956 let blueprint = basic_blueprint();
1957 let mut registry = ProviderRegistry::new();
1958 registry.register(
1959 "anthropic".to_string(),
1960 Arc::new(MockProvider {
1961 content: "no tools here".to_string(),
1962 tool_calls: vec![ToolCall {
1968 id: "call_1".to_string(),
1969 name: "write_file".to_string(),
1970 arguments: serde_json::json!({}),
1971 thought_signature: None,
1972 }],
1973 }),
1974 );
1975
1976 let tc = TestCase {
1977 name: "tool_test".to_string(),
1978 input: "run a command".to_string(),
1979 expect_contains: None,
1980 expect_tool_call: Some("bash".to_string()),
1981 max_tokens: None,
1982 };
1983
1984 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1985 assert!(!result.unwrap());
1986 }
1987
1988 #[tokio::test]
1989 async fn run_test_case_fails_both_assertions() {
1990 let blueprint = basic_blueprint();
1991 let mut registry = ProviderRegistry::new();
1992 registry.register(
1993 "anthropic".to_string(),
1994 Arc::new(MockProvider {
1995 content: "unrelated content".to_string(),
1996 tool_calls: vec![],
1997 }),
1998 );
1999
2000 let tc = TestCase {
2001 name: "both".to_string(),
2002 input: "do stuff".to_string(),
2003 expect_contains: Some("expected".to_string()),
2004 expect_tool_call: Some("write_file".to_string()),
2005 max_tokens: None,
2006 };
2007
2008 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2009 assert!(!result.unwrap());
2010 }
2011
2012 #[tokio::test]
2013 async fn run_test_case_no_assertions_always_passes() {
2014 let blueprint = basic_blueprint();
2015 let mut registry = ProviderRegistry::new();
2016 registry.register(
2017 "anthropic".to_string(),
2018 Arc::new(MockProvider {
2019 content: "anything".to_string(),
2020 tool_calls: vec![],
2021 }),
2022 );
2023
2024 let tc = TestCase {
2025 name: "no_assertions".to_string(),
2026 input: "hi".to_string(),
2027 expect_contains: None,
2028 expect_tool_call: None,
2029 max_tokens: None,
2030 };
2031
2032 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2033 assert!(result.unwrap());
2034 }
2035
2036 #[tokio::test]
2037 async fn run_test_case_provider_not_registered_errors() {
2038 let blueprint = basic_blueprint();
2039 let registry = ProviderRegistry::new(); let tc = TestCase {
2042 name: "no_provider".to_string(),
2043 input: "hi".to_string(),
2044 expect_contains: Some("x".to_string()),
2045 expect_tool_call: None,
2046 max_tokens: None,
2047 };
2048
2049 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2050 let err = result.unwrap_err().to_string();
2051 assert!(err.contains("not configured"));
2052 }
2053
2054 #[tokio::test]
2055 async fn no_temperature_provider_metadata_is_exercised() {
2056 let p = NoTemperatureProvider;
2057 assert_eq!(p.name(), "no-temperature");
2058 assert_eq!(p.count_tokens("abcd", "m").await, 4);
2059 assert_eq!(p.max_context_tokens("m"), 8192);
2060 }
2061
2062 #[tokio::test]
2063 async fn run_test_case_omits_temperature_when_provider_lacks_it() {
2064 let blueprint = basic_blueprint();
2065 let mut registry = ProviderRegistry::new();
2066 registry.register("anthropic".to_string(), Arc::new(NoTemperatureProvider));
2067 let tc = TestCase {
2068 name: "no_temp".to_string(),
2069 input: "hi".to_string(),
2070 expect_contains: Some("cold".to_string()),
2071 expect_tool_call: None,
2072 max_tokens: None,
2073 };
2074 assert!(
2075 run_test_case(&blueprint, ®istry, &tc, &Default::default())
2076 .await
2077 .unwrap()
2078 );
2079 }
2080
2081 #[tokio::test]
2082 async fn run_test_case_long_input_runs() {
2083 let blueprint = basic_blueprint();
2085 let mut registry = ProviderRegistry::new();
2086 registry.register(
2087 "anthropic".to_string(),
2088 Arc::new(MockProvider {
2089 content: "response text mentioning keyword".to_string(),
2090 tool_calls: vec![],
2091 }),
2092 );
2093
2094 let tc = TestCase {
2095 name: "long_input".to_string(),
2096 input: "x".repeat(500),
2097 expect_contains: Some("keyword".to_string()),
2098 expect_tool_call: None,
2099 max_tokens: None,
2100 };
2101
2102 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2103 assert!(result.unwrap());
2104 }
2105
2106 fn mock_registry_builder(
2127 content: &'static str,
2128 tool_calls: Vec<ToolCall>,
2129 ) -> impl FnOnce(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
2130 move |_config: &Config| {
2131 let mut reg = ProviderRegistry::new();
2132 reg.register(
2133 "anthropic".to_string(),
2134 Arc::new(MockProvider {
2135 content: content.to_string(),
2136 tool_calls,
2137 }),
2138 );
2139 Ok(reg)
2140 }
2141 }
2142
2143 fn write_project_with_test_file(project: &std::path::Path, test_toml: &str) {
2144 let manifest = r#"
2145[agent]
2146name = "test-agent"
2147version = "0.1.0"
2148description = "test"
2149
2150[stages.main]
2151model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2152"#;
2153 write_test_agent(project, manifest);
2154 let tests_dir = project.join("tests");
2155 std::fs::create_dir_all(&tests_dir).unwrap();
2156 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
2157 }
2158
2159 #[tokio::test]
2160 async fn execute_with_registry_non_dry_run_all_pass() {
2161 crate::config::with_isolated_config_path_async(
2162 "test-rs-non-dry-run-all-pass",
2163 |_fake_dir| async move {
2164 let dir = tempfile::tempdir().unwrap();
2165 let project = dir.path();
2166 write_project_with_test_file(
2167 project,
2168 r#"
2169[[test]]
2170name = "greeting"
2171input = "say hello"
2172expect_contains = "world"
2173"#,
2174 );
2175
2176 let args = TestArgs {
2177 path: Some(project.to_str().unwrap().to_string()),
2178 filter: None,
2179 dry_run: false,
2180 };
2181
2182 let result = with_tracing(|| {
2183 execute_with_registry(
2184 args,
2185 Box::new(mock_registry_builder("Hello, world!", vec![])),
2186 )
2187 })
2188 .await;
2189 assert!(result.is_ok());
2190 },
2191 )
2192 .await;
2193 }
2194
2195 #[tokio::test]
2196 async fn execute_with_registry_none_path_defaults_to_current_dir() {
2197 let args = TestArgs {
2204 path: None,
2205 filter: None,
2206 dry_run: true,
2207 };
2208 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2209 assert!(result.is_err());
2210 assert!(
2211 result
2212 .unwrap_err()
2213 .to_string()
2214 .contains("No agent.leviath found")
2215 );
2216 }
2217
2218 #[tokio::test]
2219 async fn execute_with_registry_non_dry_run_failure_bails_with_count() {
2220 crate::config::with_isolated_config_path_async(
2221 "test-rs-non-dry-run-failure-bails-with-count",
2222 |_fake_dir| async move {
2223 let dir = tempfile::tempdir().unwrap();
2224 let project = dir.path();
2225 write_project_with_test_file(
2226 project,
2227 r#"
2228[[test]]
2229name = "greeting"
2230input = "say hello"
2231expect_contains = "world"
2232"#,
2233 );
2234
2235 let args = TestArgs {
2236 path: Some(project.to_str().unwrap().to_string()),
2237 filter: None,
2238 dry_run: false,
2239 };
2240
2241 let result =
2242 execute_with_registry(args, Box::new(mock_registry_builder("goodbye", vec![])))
2243 .await;
2244 let err = result.unwrap_err().to_string();
2245 assert!(err.contains("1 test(s) failed"));
2246 },
2247 )
2248 .await;
2249 }
2250
2251 #[tokio::test]
2252 async fn execute_with_registry_non_dry_run_applies_filter() {
2253 crate::config::with_isolated_config_path_async(
2254 "test-rs-non-dry-run-applies-filter",
2255 |_fake_dir| async move {
2256 let dir = tempfile::tempdir().unwrap();
2257 let project = dir.path();
2258 write_project_with_test_file(
2259 project,
2260 r#"
2261[[test]]
2262name = "keep_me"
2263input = "say hello"
2264expect_contains = "world"
2265
2266[[test]]
2267name = "skip_me"
2268input = "say hello"
2269expect_contains = "unmatchable content"
2270"#,
2271 );
2272
2273 let args = TestArgs {
2274 path: Some(project.to_str().unwrap().to_string()),
2275 filter: Some("keep".to_string()),
2276 dry_run: false,
2277 };
2278
2279 let result = execute_with_registry(
2283 args,
2284 Box::new(mock_registry_builder("Hello, world!", vec![])),
2285 )
2286 .await;
2287 assert!(result.is_ok());
2288 },
2289 )
2290 .await;
2291 }
2292
2293 #[tokio::test]
2294 async fn execute_with_registry_non_dry_run_tool_call_assertion() {
2295 crate::config::with_isolated_config_path_async(
2296 "test-rs-non-dry-run-tool-call-assertion",
2297 |_fake_dir| async move {
2298 let dir = tempfile::tempdir().unwrap();
2299 let project = dir.path();
2300 write_project_with_test_file(
2301 project,
2302 r#"
2303[[test]]
2304name = "tool_test"
2305input = "run a command"
2306expect_tool_call = "bash"
2307"#,
2308 );
2309
2310 let args = TestArgs {
2311 path: Some(project.to_str().unwrap().to_string()),
2312 filter: None,
2313 dry_run: false,
2314 };
2315
2316 let tool_calls = vec![ToolCall {
2317 id: "call_1".to_string(),
2318 name: "bash".to_string(),
2319 arguments: serde_json::json!({}),
2320 thought_signature: None,
2321 }];
2322 let result =
2323 execute_with_registry(args, Box::new(mock_registry_builder("", tool_calls)))
2324 .await;
2325 assert!(result.is_ok());
2326 },
2327 )
2328 .await;
2329 }
2330
2331 #[tokio::test]
2332 async fn execute_with_registry_non_dry_run_provider_error_counts_as_failure() {
2333 crate::config::with_isolated_config_path_async(
2334 "test-rs-non-dry-run-provider-error-counts-as-failure",
2335 |_fake_dir| async move {
2336 let dir = tempfile::tempdir().unwrap();
2337 let project = dir.path();
2338 write_project_with_test_file(
2339 project,
2340 r#"
2341[[test]]
2342name = "no_such_provider"
2343input = "hi"
2344expect_contains = "x"
2345"#,
2346 );
2347 std::fs::write(
2351 project.join("agent.leviath"),
2352 r#"
2353[agent]
2354name = "test-agent"
2355version = "0.1.0"
2356description = "test"
2357
2358[stages.main]
2359model = { provider = "nonexistent-provider", model = "x" }
2360"#,
2361 )
2362 .unwrap();
2363
2364 let args = TestArgs {
2365 path: Some(project.to_str().unwrap().to_string()),
2366 filter: None,
2367 dry_run: false,
2368 };
2369
2370 let result = execute_with_registry(
2371 args,
2372 Box::new(mock_registry_builder("irrelevant", vec![])),
2373 )
2374 .await;
2375 let err = result.unwrap_err().to_string();
2376 assert!(err.contains("1 test(s) failed"));
2377 },
2378 )
2379 .await;
2380 }
2381
2382 #[tokio::test]
2383 async fn execute_with_registry_non_dry_run_toml_malformed_errors() {
2384 crate::config::with_isolated_config_path_async(
2385 "test-rs-non-dry-run-toml-malformed-errors",
2386 |_fake_dir| async move {
2387 let dir = tempfile::tempdir().unwrap();
2388 let project = dir.path();
2389 write_project_with_test_file(project, "not valid {{{ toml");
2390
2391 let args = TestArgs {
2392 path: Some(project.to_str().unwrap().to_string()),
2393 filter: None,
2394 dry_run: false,
2395 };
2396
2397 let result = execute_with_registry(
2398 args,
2399 Box::new(mock_registry_builder("irrelevant", vec![])),
2400 )
2401 .await;
2402 assert!(result.is_err());
2403 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2404 },
2405 )
2406 .await;
2407 }
2408
2409 #[tokio::test]
2412 async fn execute_with_registry_rhai_script_passes() {
2413 crate::config::with_isolated_config_path_async(
2414 "test-rs-rhai-script-passes",
2415 |_fake_dir| async move {
2416 let dir = tempfile::tempdir().unwrap();
2417 let project = dir.path();
2418 let manifest = r#"
2419[agent]
2420name = "test-agent"
2421version = "0.1.0"
2422description = "test"
2423
2424[stages.main]
2425model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2426"#;
2427 write_test_agent(project, manifest);
2428 let tests_dir = project.join("tests");
2429 std::fs::create_dir_all(&tests_dir).unwrap();
2430 std::fs::write(tests_dir.join("script.rhai"), "true").unwrap();
2431
2432 let args = TestArgs {
2433 path: Some(project.to_str().unwrap().to_string()),
2434 filter: None,
2435 dry_run: false,
2436 };
2437
2438 let result =
2439 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2440 .await;
2441 assert!(result.is_ok());
2442 },
2443 )
2444 .await;
2445 }
2446
2447 #[tokio::test]
2448 async fn execute_with_registry_rhai_script_returns_false_fails() {
2449 crate::config::with_isolated_config_path_async(
2450 "test-rs-rhai-script-returns-false-fails",
2451 |_fake_dir| async move {
2452 let dir = tempfile::tempdir().unwrap();
2453 let project = dir.path();
2454 let manifest = r#"
2455[agent]
2456name = "test-agent"
2457version = "0.1.0"
2458description = "test"
2459
2460[stages.main]
2461model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2462"#;
2463 write_test_agent(project, manifest);
2464 let tests_dir = project.join("tests");
2465 std::fs::create_dir_all(&tests_dir).unwrap();
2466 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2467
2468 let args = TestArgs {
2469 path: Some(project.to_str().unwrap().to_string()),
2470 filter: None,
2471 dry_run: false,
2472 };
2473
2474 let result =
2475 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2476 .await;
2477 let err = result.unwrap_err().to_string();
2478 assert!(err.contains("1 test(s) failed"));
2479 },
2480 )
2481 .await;
2482 }
2483
2484 #[tokio::test]
2485 async fn execute_with_registry_rhai_script_error_fails() {
2486 crate::config::with_isolated_config_path_async(
2487 "test-rs-rhai-script-error-fails",
2488 |_fake_dir| async move {
2489 let dir = tempfile::tempdir().unwrap();
2490 let project = dir.path();
2491 let manifest = r#"
2492[agent]
2493name = "test-agent"
2494version = "0.1.0"
2495description = "test"
2496
2497[stages.main]
2498model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2499"#;
2500 write_test_agent(project, manifest);
2501 let tests_dir = project.join("tests");
2502 std::fs::create_dir_all(&tests_dir).unwrap();
2503 std::fs::write(tests_dir.join("script.rhai"), "this is not valid rhai (((")
2504 .unwrap();
2505
2506 let args = TestArgs {
2507 path: Some(project.to_str().unwrap().to_string()),
2508 filter: None,
2509 dry_run: false,
2510 };
2511
2512 let result =
2513 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2514 .await;
2515 assert!(result.is_err());
2516 },
2517 )
2518 .await;
2519 }
2520
2521 #[tokio::test]
2522 async fn execute_with_registry_rhai_script_non_bool_return_passes() {
2523 crate::config::with_isolated_config_path_async(
2524 "test-rs-rhai-script-non-bool-return-passes",
2525 |_fake_dir| async move {
2526 let dir = tempfile::tempdir().unwrap();
2527 let project = dir.path();
2528 let manifest = r#"
2529[agent]
2530name = "test-agent"
2531version = "0.1.0"
2532description = "test"
2533
2534[stages.main]
2535model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2536"#;
2537 write_test_agent(project, manifest);
2538 let tests_dir = project.join("tests");
2539 std::fs::create_dir_all(&tests_dir).unwrap();
2540 std::fs::write(tests_dir.join("script.rhai"), "42").unwrap();
2543
2544 let args = TestArgs {
2545 path: Some(project.to_str().unwrap().to_string()),
2546 filter: None,
2547 dry_run: false,
2548 };
2549
2550 let result =
2551 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2552 .await;
2553 assert!(result.is_ok());
2554 },
2555 )
2556 .await;
2557 }
2558
2559 #[tokio::test]
2560 async fn execute_with_registry_rhai_script_filter_excludes_all() {
2561 crate::config::with_isolated_config_path_async(
2562 "test-rs-rhai-script-filter-excludes-all",
2563 |_fake_dir| async move {
2564 let dir = tempfile::tempdir().unwrap();
2565 let project = dir.path();
2566 let manifest = r#"
2567[agent]
2568name = "test-agent"
2569version = "0.1.0"
2570description = "test"
2571
2572[stages.main]
2573model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2574"#;
2575 write_test_agent(project, manifest);
2576 let tests_dir = project.join("tests");
2577 std::fs::create_dir_all(&tests_dir).unwrap();
2578 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2579
2580 let args = TestArgs {
2581 path: Some(project.to_str().unwrap().to_string()),
2582 filter: Some("no-such-script".to_string()),
2583 dry_run: false,
2584 };
2585
2586 let result =
2589 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2590 .await;
2591 assert!(result.is_ok());
2592 },
2593 )
2594 .await;
2595 }
2596
2597 #[test]
2605 fn build_registry_from_config_registers_all_providers() {
2606 let config = Config {
2607 default_provider: "anthropic".to_string(),
2608 providers: crate::config::ProviderConfig {
2609 anthropic_api_key: Some("fake-anthropic-key".to_string()),
2610 openai_api_key: Some("fake-openai-key".to_string()),
2611 google_api_key: Some("fake-google-key".to_string()),
2612 claude_code_enabled: false,
2613 claude_code_binary: None,
2614 claude_code_effort: None,
2615 anthropic_cache_ttl: None,
2616 fallback_order: Vec::new(),
2617 },
2618 openrouter_api_key: Some("fake-openrouter-key".to_string()),
2619 ollama_base_url: Some("http://localhost:12345".to_string()),
2620 ..Config::default()
2621 };
2622
2623 let registry =
2624 build_registry_from_config(&config).expect("an HTTPS client builds in tests");
2625 assert!(registry.has("anthropic"));
2626 assert!(registry.has("openai"));
2627 assert!(registry.has("google"));
2628 assert!(registry.has("openrouter"));
2629 assert!(registry.has("ollama"));
2630 }
2631
2632 #[test]
2633 fn build_registry_from_config_no_keys_still_registers_ollama_with_default_url() {
2634 let config = Config::default();
2635 let registry =
2636 build_registry_from_config(&config).expect("an HTTPS client builds in tests");
2637 assert!(!registry.has("anthropic"));
2638 assert!(!registry.has("openai"));
2639 assert!(!registry.has("google"));
2640 assert!(!registry.has("openrouter"));
2641 assert!(registry.has("ollama"));
2644 }
2645
2646 #[tokio::test]
2649 async fn execute_with_registry_ignores_non_test_files_in_tests_dir() {
2650 let dir = tempfile::tempdir().unwrap();
2651 let project = dir.path();
2652 let manifest = r#"
2653[agent]
2654name = "test-agent"
2655version = "0.1.0"
2656description = "test"
2657
2658[stages.main]
2659model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2660"#;
2661 write_test_agent(project, manifest);
2662 let tests_dir = project.join("tests");
2663 std::fs::create_dir_all(&tests_dir).unwrap();
2664 std::fs::write(tests_dir.join("readme.txt"), "this file should be ignored").unwrap();
2667 let args = TestArgs {
2668 path: Some(project.to_str().unwrap().to_string()),
2669 filter: None,
2670 dry_run: true,
2671 };
2672 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2673 assert!(result.is_ok());
2674 }
2675
2676 #[tokio::test]
2679 async fn error_provider_trivial_trait_methods() {
2680 let provider = ErrorProvider;
2681 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2682 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2683 assert_eq!(provider.name(), "error-provider");
2684 let caps = provider.capabilities("any-model");
2685 let _ = caps; }
2687
2688 #[tokio::test]
2691 async fn mock_provider_trivial_trait_methods() {
2692 let provider = MockProvider {
2693 content: "x".to_string(),
2694 tool_calls: vec![],
2695 };
2696 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2697 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2698 assert_eq!(provider.name(), "mock");
2699 }
2700
2701 #[test]
2702 fn a_registry_needs_an_https_client_it_can_build() {
2703 let mut config = Config::default();
2706 config.providers.anthropic_api_key = Some("k".to_string());
2707 let err = build_registry_from_config_with(&config, &|_t| {
2708 Err(leviath_providers::provider::malformed_url_error())
2709 })
2710 .err()
2711 .expect("a failing client factory should fail the registry");
2712 assert!(err.to_string().contains("root certificate store"));
2713 }
2714
2715 #[tokio::test]
2716 async fn a_real_run_stops_when_the_registry_will_not_build() {
2717 crate::config::with_isolated_config_path_async(
2720 "test-a_real_run_stops_when_the_registry_will_not_build",
2721 |_fake_dir| async move {
2722 let dir = tempfile::tempdir().expect("tempdir");
2723 let project = dir.path();
2724 write_project_with_test_file(project, "[[test]]\nname = \"t\"\ninput = \"hi\"\n");
2725 let args = TestArgs {
2726 path: Some(project.to_str().expect("utf-8 path").to_string()),
2727 filter: None,
2728 dry_run: false,
2729 };
2730 let failing: RegistryBuilder = Box::new(|_config: &Config| {
2731 Err(leviath_providers::ProviderError::ClientBuild(
2732 "no roots".to_string(),
2733 ))
2734 });
2735 let err = execute_with_registry(args, failing)
2736 .await
2737 .expect_err("a failing registry builder should stop the run");
2738 assert!(err.to_string().contains("no roots"), "{err}");
2739 },
2740 )
2741 .await;
2742 }
2743}