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 previous_system_hash: None,
415 });
416 let caps = provider.capabilities(model_name);
417 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
418 let max_tokens = resolved_max_tokens(test.max_tokens, remaining.min(caps.max_output_tokens));
422 let temperature = if caps.supports_temperature { 0.7 } else { 0.0 };
423 let request = InferenceRequest {
424 system: assembled.system_blocks,
425 messages: assembled.messages,
426 model: model_name.to_string(),
427 max_tokens,
428 temperature,
429 tools: stage_tools(stage),
434 extra: serde_json::Value::Null,
435 request_timeout_secs: None,
436 };
437
438 let response = provider
439 .infer(&request)
440 .await
441 .map_err(|e| anyhow::anyhow!("Inference failed: {}", e))?;
442
443 let mut all_passed = true;
445
446 if let Some(ref expected) = test.expect_contains {
447 let content_lower = response.content.to_lowercase();
448 let expected_lower = expected.to_lowercase();
449 if !content_lower.contains(&expected_lower) {
450 println!(
451 " expect_contains failed: response does not contain '{}'",
452 expected
453 );
454 println!(" response: {}", truncate_str(&response.content, 200));
455 all_passed = false;
456 }
457 }
458
459 if let Some(ref expected_tool) = test.expect_tool_call {
460 let has_tool = response
461 .tool_calls
462 .iter()
463 .any(|tc| tc.name == *expected_tool);
464 if !has_tool {
465 println!(
466 " expect_tool_call failed: no tool call to '{}'",
467 expected_tool
468 );
469 let tool_names: Vec<&str> = response
470 .tool_calls
471 .iter()
472 .map(|tc| tc.name.as_str())
473 .collect();
474 println!(" actual tool calls: {:?}", tool_names);
475 all_passed = false;
476 }
477 }
478
479 Ok(all_passed)
480}
481
482fn validate_test_case(test: &TestCase) -> bool {
484 if test.name.is_empty() {
485 return false;
486 }
487 if test.input.is_empty() {
488 return false;
489 }
490 if test.expect_contains.is_none() && test.expect_tool_call.is_none() {
492 return false;
493 }
494 true
495}
496
497fn truncate_str(s: &str, max: usize) -> String {
502 if s.len() <= max {
503 s.to_string()
504 } else {
505 format!("{}...", truncate_at_boundary(s, max))
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::test_support::{with_tracing, write_test_agent};
513
514 #[test]
517 fn validate_test_case_valid_with_expect_contains() {
518 let tc = TestCase {
519 name: "basic".to_string(),
520 input: "hello".to_string(),
521 expect_contains: Some("world".to_string()),
522 expect_tool_call: None,
523 max_tokens: None,
524 };
525 assert!(validate_test_case(&tc));
526 }
527
528 #[test]
529 fn validate_test_case_valid_with_expect_tool_call() {
530 let tc = TestCase {
531 name: "tool_test".to_string(),
532 input: "do something".to_string(),
533 expect_contains: None,
534 expect_tool_call: Some("bash".to_string()),
535 max_tokens: None,
536 };
537 assert!(validate_test_case(&tc));
538 }
539
540 #[test]
541 fn validate_test_case_valid_with_both_assertions() {
542 let tc = TestCase {
543 name: "both".to_string(),
544 input: "test".to_string(),
545 expect_contains: Some("output".to_string()),
546 expect_tool_call: Some("read_file".to_string()),
547 max_tokens: Some(100),
548 };
549 assert!(validate_test_case(&tc));
550 }
551
552 #[test]
553 fn validate_test_case_empty_name_fails() {
554 let tc = TestCase {
555 name: String::new(),
556 input: "hello".to_string(),
557 expect_contains: Some("world".to_string()),
558 expect_tool_call: None,
559 max_tokens: None,
560 };
561 assert!(!validate_test_case(&tc));
562 }
563
564 #[test]
565 fn validate_test_case_empty_input_fails() {
566 let tc = TestCase {
567 name: "test".to_string(),
568 input: String::new(),
569 expect_contains: Some("world".to_string()),
570 expect_tool_call: None,
571 max_tokens: None,
572 };
573 assert!(!validate_test_case(&tc));
574 }
575
576 #[test]
577 fn validate_test_case_no_assertions_fails() {
578 let tc = TestCase {
579 name: "test".to_string(),
580 input: "hello".to_string(),
581 expect_contains: None,
582 expect_tool_call: None,
583 max_tokens: None,
584 };
585 assert!(!validate_test_case(&tc));
586 }
587
588 #[test]
591 fn truncate_str_short() {
592 assert_eq!(truncate_str("hello", 10), "hello");
593 }
594
595 #[test]
596 fn truncate_str_exact() {
597 assert_eq!(truncate_str("hello", 5), "hello");
598 }
599
600 #[test]
601 fn truncate_str_long() {
602 assert_eq!(truncate_str("hello world", 5), "hello...");
603 }
604
605 #[test]
606 fn truncate_str_empty() {
607 assert_eq!(truncate_str("", 5), "");
608 }
609
610 #[test]
613 fn parse_test_file_toml() {
614 let toml_content = r#"
615[[test]]
616name = "greeting"
617input = "Say hello"
618expect_contains = "hello"
619
620[[test]]
621name = "tool_use"
622input = "Read file.txt"
623expect_tool_call = "read_file"
624max_tokens = 500
625"#;
626 let test_file: TestFile = toml::from_str(toml_content).unwrap();
627 assert_eq!(test_file.test.len(), 2);
628 assert_eq!(test_file.test[0].name, "greeting");
629 assert_eq!(test_file.test[0].input, "Say hello");
630 assert_eq!(test_file.test[0].expect_contains.as_deref(), Some("hello"));
631 assert!(test_file.test[0].expect_tool_call.is_none());
632 assert!(test_file.test[0].max_tokens.is_none());
633
634 assert_eq!(test_file.test[1].name, "tool_use");
635 assert_eq!(
636 test_file.test[1].expect_tool_call.as_deref(),
637 Some("read_file")
638 );
639 assert_eq!(test_file.test[1].max_tokens, Some(500));
640 }
641
642 fn test_model() -> leviath_core::blueprint::ModelConfig {
645 leviath_core::blueprint::ModelConfig::new("anthropic".to_string(), "m".to_string())
646 }
647
648 #[test]
652 fn a_case_max_tokens_narrows_the_ceiling_and_never_widens_it() {
653 let ceiling = 4_000;
654 assert_eq!(
655 resolved_max_tokens(Some(500), ceiling),
656 500,
657 "a smaller case cap wins"
658 );
659 assert_eq!(
660 resolved_max_tokens(Some(99_000), ceiling),
661 ceiling,
662 "a case may not ask for more than the model allows"
663 );
664 assert_eq!(
665 resolved_max_tokens(None, ceiling),
666 ceiling,
667 "no cap means the full ceiling"
668 );
669 }
670
671 #[test]
675 fn a_stage_advertises_its_tools_so_a_tool_call_is_possible() {
676 let mut stage = leviath_core::Stage::new("s".to_string(), test_model());
677 stage.available_tools = vec!["read_file".to_string(), "write_file".to_string()];
678 let tools = stage_tools(&stage);
679 let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
680 assert!(names.contains(&"read_file"), "got {names:?}");
681 assert!(names.contains(&"write_file"), "got {names:?}");
682 }
683
684 #[test]
687 fn a_stage_with_no_tools_advertises_none() {
688 let stage = leviath_core::Stage::new("s".to_string(), test_model());
689 assert!(stage_tools(&stage).is_empty());
690 }
691
692 #[test]
695 fn an_unknown_tool_name_is_not_advertised() {
696 let mut stage = leviath_core::Stage::new("s".to_string(), test_model());
697 stage.available_tools = vec!["definitely_not_a_tool".to_string()];
698 assert!(stage_tools(&stage).is_empty());
699 }
700
701 #[test]
702 fn parse_test_file_minimal() {
703 let toml_content = r#"
704[[test]]
705name = "min"
706input = "test"
707expect_contains = "ok"
708"#;
709 let test_file: TestFile = toml::from_str(toml_content).unwrap();
710 assert_eq!(test_file.test.len(), 1);
711 }
712
713 #[test]
714 fn parse_test_file_invalid_toml_errors() {
715 let result: Result<TestFile, _> = toml::from_str("not valid toml {{{{");
716 assert!(result.is_err());
717 }
718
719 #[tokio::test]
722 async fn dry_run_with_temp_project() {
723 let dir = tempfile::tempdir().unwrap();
724 let project = dir.path();
725
726 let manifest = r#"
728[agent]
729name = "test-agent"
730version = "0.1.0"
731description = "test"
732
733[stages.main]
734model = { provider = "anthropic", model = "claude-sonnet-4-6" }
735"#;
736 write_test_agent(project, manifest);
737
738 let tests_dir = project.join("tests");
740 std::fs::create_dir_all(&tests_dir).unwrap();
741 let test_toml = r#"
742[[test]]
743name = "valid_test"
744input = "hello"
745expect_contains = "world"
746"#;
747 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
748
749 let args = TestArgs {
750 path: Some(project.to_str().unwrap().to_string()),
751 filter: None,
752 dry_run: true,
753 };
754
755 let result = execute(args).await;
756 assert!(result.is_ok());
757 }
758
759 #[tokio::test]
760 async fn dry_run_no_tests_dir() {
761 let dir = tempfile::tempdir().unwrap();
762 let project = dir.path();
763
764 let manifest = r#"
765[agent]
766name = "test-agent"
767version = "0.1.0"
768description = "test"
769
770[stages.main]
771model = { provider = "anthropic", model = "claude-sonnet-4-6" }
772"#;
773 write_test_agent(project, manifest);
774
775 let args = TestArgs {
776 path: Some(project.to_str().unwrap().to_string()),
777 filter: None,
778 dry_run: true,
779 };
780
781 let result = execute(args).await;
783 assert!(result.is_ok());
784 }
785
786 #[tokio::test]
787 async fn execute_no_manifest_errors() {
788 let dir = tempfile::tempdir().unwrap();
789 let args = TestArgs {
790 path: Some(dir.path().to_str().unwrap().to_string()),
791 filter: None,
792 dry_run: true,
793 };
794 let result = execute(args).await;
795 assert!(result.is_err());
796 assert!(result.unwrap_err().to_string().contains("agent.leviath"));
797 }
798
799 #[test]
802 fn test_case_all_fields_from_toml() {
803 let toml_content = r#"
804[[test]]
805name = "full_test"
806input = "full input"
807expect_contains = "expected"
808expect_tool_call = "bash"
809max_tokens = 1000
810"#;
811 let test_file: TestFile = toml::from_str(toml_content).unwrap();
812 let tc = &test_file.test[0];
813 assert_eq!(tc.name, "full_test");
814 assert_eq!(tc.input, "full input");
815 assert_eq!(tc.expect_contains.as_deref(), Some("expected"));
816 assert_eq!(tc.expect_tool_call.as_deref(), Some("bash"));
817 assert_eq!(tc.max_tokens, Some(1000));
818 }
819
820 #[test]
821 fn test_case_minimal_from_toml() {
822 let toml_content = r#"
823[[test]]
824name = "min"
825input = "hello"
826expect_contains = "world"
827"#;
828 let test_file: TestFile = toml::from_str(toml_content).unwrap();
829 let tc = &test_file.test[0];
830 assert!(tc.expect_tool_call.is_none());
831 assert!(tc.max_tokens.is_none());
832 }
833
834 #[test]
835 fn test_file_multiple_cases() {
836 let toml_content = r#"
837[[test]]
838name = "case1"
839input = "a"
840expect_contains = "b"
841
842[[test]]
843name = "case2"
844input = "c"
845expect_tool_call = "read_file"
846
847[[test]]
848name = "case3"
849input = "d"
850expect_contains = "e"
851expect_tool_call = "bash"
852max_tokens = 500
853"#;
854 let test_file: TestFile = toml::from_str(toml_content).unwrap();
855 assert_eq!(test_file.test.len(), 3);
856 }
857
858 #[test]
861 fn validate_test_case_whitespace_name_passes() {
862 let tc = TestCase {
864 name: " ".to_string(),
865 input: "hello".to_string(),
866 expect_contains: Some("world".to_string()),
867 expect_tool_call: None,
868 max_tokens: None,
869 };
870 assert!(validate_test_case(&tc));
871 }
872
873 #[test]
876 fn truncate_str_one_char_max() {
877 assert_eq!(truncate_str("hello", 1), "h...");
878 }
879
880 #[test]
881 fn truncate_str_unicode() {
882 assert_eq!(truncate_str("abcde", 3), "abc...");
883 assert_eq!(truncate_str("abc🎉def", 4), "abc...");
887 assert_eq!(truncate_str("abc🎉def", 6), "abc...");
888 assert_eq!(truncate_str("abc🎉def", 7), "abc🎉...");
890 assert_eq!(truncate_str("🎉🎉", 2), "...");
893 }
894
895 #[tokio::test]
898 async fn dry_run_with_filter_matches() {
899 let dir = tempfile::tempdir().unwrap();
900 let project = dir.path();
901 let manifest = r#"
902[agent]
903name = "test-agent"
904version = "0.1.0"
905description = "test"
906
907[stages.main]
908model = { provider = "anthropic", model = "claude-sonnet-4-6" }
909"#;
910 write_test_agent(project, manifest);
911 let tests_dir = project.join("tests");
912 std::fs::create_dir_all(&tests_dir).unwrap();
913 let test_toml = r#"
914[[test]]
915name = "alpha_test"
916input = "hello"
917expect_contains = "world"
918
919[[test]]
920name = "beta_test"
921input = "hello"
922expect_contains = "world"
923"#;
924 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
925
926 let args = TestArgs {
927 path: Some(project.to_str().unwrap().to_string()),
928 filter: Some("alpha".to_string()),
929 dry_run: true,
930 };
931 let result = execute(args).await;
932 assert!(result.is_ok());
933 }
934
935 #[tokio::test]
936 async fn dry_run_failing_test_case() {
937 let dir = tempfile::tempdir().unwrap();
938 let project = dir.path();
939 let manifest = r#"
940[agent]
941name = "test-agent"
942version = "0.1.0"
943description = "test"
944
945[stages.main]
946model = { provider = "anthropic", model = "claude-sonnet-4-6" }
947"#;
948 write_test_agent(project, manifest);
949 let tests_dir = project.join("tests");
950 std::fs::create_dir_all(&tests_dir).unwrap();
951 let test_toml = r#"
953[[test]]
954name = "bad_test"
955input = "hello"
956"#;
957 std::fs::write(tests_dir.join("fail.toml"), test_toml).unwrap();
958
959 let args = TestArgs {
960 path: Some(project.to_str().unwrap().to_string()),
961 filter: None,
962 dry_run: true,
963 };
964 let result = execute(args).await;
965 assert!(result.is_err()); }
967
968 #[test]
971 fn validate_test_case_with_max_tokens_only_and_no_assertion_fails() {
972 let tc = TestCase {
973 name: "has-max-tokens".to_string(),
974 input: "test".to_string(),
975 expect_contains: None,
976 expect_tool_call: None,
977 max_tokens: Some(500),
978 };
979 assert!(!validate_test_case(&tc));
980 }
981
982 #[test]
983 fn validate_test_case_with_only_tool_call_assertion() {
984 let tc = TestCase {
985 name: "tool-only".to_string(),
986 input: "do it".to_string(),
987 expect_contains: None,
988 expect_tool_call: Some("write_file".to_string()),
989 max_tokens: None,
990 };
991 assert!(validate_test_case(&tc));
992 }
993
994 #[test]
997 fn truncate_str_zero_max() {
998 assert_eq!(truncate_str("hello", 0), "...");
999 }
1000
1001 #[test]
1002 fn truncate_str_large_max() {
1003 let s = "short";
1004 assert_eq!(truncate_str(s, 1000), "short");
1005 }
1006
1007 #[test]
1010 fn parse_test_file_empty_tests_array() {
1011 let toml_content = r#"
1012test = []
1013"#;
1014 let test_file: TestFile = toml::from_str(toml_content).unwrap();
1015 assert!(test_file.test.is_empty());
1016 }
1017
1018 #[test]
1019 fn parse_test_file_missing_test_key_errors() {
1020 let result: Result<TestFile, _> = toml::from_str("something_else = 42");
1021 assert!(result.is_err());
1022 }
1023
1024 #[tokio::test]
1027 async fn dry_run_with_filter_no_match() {
1028 let dir = tempfile::tempdir().unwrap();
1029 let project = dir.path();
1030 let manifest = r#"
1031[agent]
1032name = "test-agent"
1033version = "0.1.0"
1034description = "test"
1035
1036[stages.main]
1037model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1038"#;
1039 write_test_agent(project, manifest);
1040 let tests_dir = project.join("tests");
1041 std::fs::create_dir_all(&tests_dir).unwrap();
1042 let test_toml = r#"
1043[[test]]
1044name = "alpha_test"
1045input = "hello"
1046expect_contains = "world"
1047"#;
1048 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
1049
1050 let args = TestArgs {
1051 path: Some(project.to_str().unwrap().to_string()),
1052 filter: Some("nonexistent_filter".to_string()),
1053 dry_run: true,
1054 };
1055 let result = execute(args).await;
1057 assert!(result.is_ok());
1058 }
1059
1060 #[tokio::test]
1063 async fn dry_run_with_rhai_script_passing() {
1064 let dir = tempfile::tempdir().unwrap();
1065 let project = dir.path();
1066 let manifest = r#"
1067[agent]
1068name = "test-agent"
1069version = "0.1.0"
1070description = "test"
1071
1072[stages.main]
1073model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1074"#;
1075 write_test_agent(project, manifest);
1076 let tests_dir = project.join("tests");
1077 std::fs::create_dir_all(&tests_dir).unwrap();
1078
1079 std::fs::write(tests_dir.join("pass_test.rhai"), "true").unwrap();
1081
1082 let args = TestArgs {
1083 path: Some(project.to_str().unwrap().to_string()),
1084 filter: None,
1085 dry_run: true,
1086 };
1087 let result = execute(args).await;
1088 assert!(result.is_ok());
1089 }
1090
1091 #[tokio::test]
1092 async fn dry_run_with_rhai_script_returning_false() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let project = dir.path();
1095 let manifest = r#"
1096[agent]
1097name = "test-agent"
1098version = "0.1.0"
1099description = "test"
1100
1101[stages.main]
1102model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1103"#;
1104 write_test_agent(project, manifest);
1105 let tests_dir = project.join("tests");
1106 std::fs::create_dir_all(&tests_dir).unwrap();
1107
1108 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1110
1111 let args = TestArgs {
1112 path: Some(project.to_str().unwrap().to_string()),
1113 filter: None,
1114 dry_run: true,
1115 };
1116 let result = execute(args).await;
1117 assert!(result.is_err()); }
1119
1120 #[tokio::test]
1121 async fn dry_run_with_rhai_script_error() {
1122 let dir = tempfile::tempdir().unwrap();
1123 let project = dir.path();
1124 let manifest = r#"
1125[agent]
1126name = "test-agent"
1127version = "0.1.0"
1128description = "test"
1129
1130[stages.main]
1131model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1132"#;
1133 write_test_agent(project, manifest);
1134 let tests_dir = project.join("tests");
1135 std::fs::create_dir_all(&tests_dir).unwrap();
1136
1137 std::fs::write(
1139 tests_dir.join("error_test.rhai"),
1140 "throw \"intentional error\"",
1141 )
1142 .unwrap();
1143
1144 let args = TestArgs {
1145 path: Some(project.to_str().unwrap().to_string()),
1146 filter: None,
1147 dry_run: true,
1148 };
1149 let result = execute(args).await;
1150 assert!(result.is_err()); }
1152
1153 #[tokio::test]
1154 async fn dry_run_with_rhai_non_bool_result_passes() {
1155 let dir = tempfile::tempdir().unwrap();
1156 let project = dir.path();
1157 let manifest = r#"
1158[agent]
1159name = "test-agent"
1160version = "0.1.0"
1161description = "test"
1162
1163[stages.main]
1164model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1165"#;
1166 write_test_agent(project, manifest);
1167 let tests_dir = project.join("tests");
1168 std::fs::create_dir_all(&tests_dir).unwrap();
1169
1170 std::fs::write(tests_dir.join("nonbool_test.rhai"), "42").unwrap();
1172
1173 let args = TestArgs {
1174 path: Some(project.to_str().unwrap().to_string()),
1175 filter: None,
1176 dry_run: true,
1177 };
1178 let result = execute(args).await;
1179 assert!(result.is_ok()); }
1181
1182 #[tokio::test]
1183 async fn dry_run_with_rhai_filter_matches() {
1184 let dir = tempfile::tempdir().unwrap();
1185 let project = dir.path();
1186 let manifest = r#"
1187[agent]
1188name = "test-agent"
1189version = "0.1.0"
1190description = "test"
1191
1192[stages.main]
1193model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1194"#;
1195 write_test_agent(project, manifest);
1196 let tests_dir = project.join("tests");
1197 std::fs::create_dir_all(&tests_dir).unwrap();
1198
1199 std::fs::write(tests_dir.join("fail_test.rhai"), "false").unwrap();
1201 std::fs::write(tests_dir.join("good_test.rhai"), "true").unwrap();
1203
1204 let args = TestArgs {
1205 path: Some(project.to_str().unwrap().to_string()),
1206 filter: Some("good".to_string()),
1207 dry_run: true,
1208 };
1209 let result = execute(args).await;
1210 assert!(result.is_ok()); }
1212
1213 #[tokio::test]
1216 async fn dry_run_with_multiple_test_files() {
1217 let dir = tempfile::tempdir().unwrap();
1218 let project = dir.path();
1219 let manifest = r#"
1220[agent]
1221name = "test-agent"
1222version = "0.1.0"
1223description = "test"
1224
1225[stages.main]
1226model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1227"#;
1228 write_test_agent(project, manifest);
1229 let tests_dir = project.join("tests");
1230 std::fs::create_dir_all(&tests_dir).unwrap();
1231
1232 let test1 = r#"
1233[[test]]
1234name = "test_a"
1235input = "hello"
1236expect_contains = "world"
1237"#;
1238 let test2 = r#"
1239[[test]]
1240name = "test_b"
1241input = "foo"
1242expect_tool_call = "bar"
1243"#;
1244 std::fs::write(tests_dir.join("file1.toml"), test1).unwrap();
1245 std::fs::write(tests_dir.join("file2.toml"), test2).unwrap();
1246
1247 let args = TestArgs {
1248 path: Some(project.to_str().unwrap().to_string()),
1249 filter: None,
1250 dry_run: true,
1251 };
1252 let result = execute(args).await;
1253 assert!(result.is_ok());
1254 }
1255
1256 #[tokio::test]
1259 async fn dry_run_with_invalid_toml_file() {
1260 let dir = tempfile::tempdir().unwrap();
1261 let project = dir.path();
1262 let manifest = r#"
1263[agent]
1264name = "test-agent"
1265version = "0.1.0"
1266description = "test"
1267
1268[stages.main]
1269model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1270"#;
1271 write_test_agent(project, manifest);
1272 let tests_dir = project.join("tests");
1273 std::fs::create_dir_all(&tests_dir).unwrap();
1274 std::fs::write(tests_dir.join("bad.toml"), "not valid {{{ toml").unwrap();
1275
1276 let args = TestArgs {
1277 path: Some(project.to_str().unwrap().to_string()),
1278 filter: None,
1279 dry_run: true,
1280 };
1281 let result = execute(args).await;
1282 assert!(result.is_err());
1283 }
1284
1285 use leviath_providers::{
1299 FinishReason, InferenceRequest, InferenceResponse, Provider, TokenUsage, ToolCall,
1300 };
1301
1302 struct MockProvider {
1305 content: String,
1306 tool_calls: Vec<ToolCall>,
1307 }
1308
1309 #[async_trait::async_trait]
1310 impl Provider for MockProvider {
1311 async fn infer(
1312 &self,
1313 _request: &InferenceRequest,
1314 ) -> leviath_providers::Result<InferenceResponse> {
1315 Ok(InferenceResponse {
1316 content: self.content.clone(),
1317 tool_calls: self.tool_calls.clone(),
1318 tokens_used: TokenUsage {
1319 prompt_tokens: 1,
1320 completion_tokens: 1,
1321 total_tokens: 2,
1322 cached_tokens: 0,
1323 cache_write_tokens: 0,
1324 },
1325 finish_reason: FinishReason::Complete,
1326 })
1327 }
1328
1329 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1330 text.len()
1331 }
1332
1333 fn max_context_tokens(&self, _model: &str) -> usize {
1334 8192
1335 }
1336
1337 fn name(&self) -> &str {
1338 "mock"
1339 }
1340
1341 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1342 leviath_providers::ModelCapabilities::default()
1343 }
1344 }
1345
1346 struct NoTemperatureProvider;
1349
1350 #[async_trait::async_trait]
1351 impl Provider for NoTemperatureProvider {
1352 async fn infer(
1353 &self,
1354 _request: &InferenceRequest,
1355 ) -> leviath_providers::Result<InferenceResponse> {
1356 Ok(InferenceResponse {
1357 content: "cold hello".to_string(),
1358 tool_calls: vec![],
1359 tokens_used: TokenUsage {
1360 prompt_tokens: 1,
1361 completion_tokens: 1,
1362 total_tokens: 2,
1363 cached_tokens: 0,
1364 cache_write_tokens: 0,
1365 },
1366 finish_reason: FinishReason::Complete,
1367 })
1368 }
1369
1370 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1371 text.len()
1372 }
1373
1374 fn max_context_tokens(&self, _model: &str) -> usize {
1375 8192
1376 }
1377
1378 fn name(&self) -> &str {
1379 "no-temperature"
1380 }
1381
1382 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1383 leviath_providers::ModelCapabilities {
1384 supports_temperature: false,
1385 ..Default::default()
1386 }
1387 }
1388 }
1389
1390 struct ErrorProvider;
1392
1393 #[async_trait::async_trait]
1394 impl Provider for ErrorProvider {
1395 async fn infer(
1396 &self,
1397 _request: &InferenceRequest,
1398 ) -> leviath_providers::Result<InferenceResponse> {
1399 Err(leviath_providers::ProviderError::ApiError(
1400 "simulated inference error".to_string(),
1401 ))
1402 }
1403
1404 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1405 text.len()
1406 }
1407
1408 fn max_context_tokens(&self, _model: &str) -> usize {
1409 8192
1410 }
1411
1412 fn name(&self) -> &str {
1413 "error-provider"
1414 }
1415
1416 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1417 leviath_providers::ModelCapabilities::default()
1418 }
1419 }
1420
1421 fn basic_blueprint() -> leviath_core::Blueprint {
1422 let manifest = r#"
1423[agent]
1424name = "test-agent"
1425version = "0.1.0"
1426description = "test"
1427
1428[stages.main]
1429model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1430"#;
1431 parse_manifest(manifest).unwrap()
1432 }
1433
1434 fn blueprint_with_tool_results_region() -> leviath_core::Blueprint {
1437 let manifest = r#"
1438[agent]
1439name = "test-agent"
1440version = "0.1.0"
1441description = "test"
1442
1443[stages.main]
1444model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1445
1446[context.regions.tool_results]
1447kind = "temporary"
1448max_tokens = 5000
1449"#;
1450 parse_manifest(manifest).unwrap()
1451 }
1452
1453 struct RecordingProvider {
1457 seen: std::sync::Arc<std::sync::Mutex<Option<InferenceRequest>>>,
1458 }
1459
1460 #[async_trait::async_trait]
1461 impl Provider for RecordingProvider {
1462 async fn infer(
1463 &self,
1464 request: &InferenceRequest,
1465 ) -> leviath_providers::Result<InferenceResponse> {
1466 *self.seen.lock().unwrap() = Some(request.clone());
1467 Ok(InferenceResponse {
1468 content: "recorded".to_string(),
1469 tool_calls: vec![],
1470 tokens_used: TokenUsage {
1471 prompt_tokens: 1,
1472 completion_tokens: 1,
1473 total_tokens: 2,
1474 cached_tokens: 0,
1475 cache_write_tokens: 0,
1476 },
1477 finish_reason: FinishReason::Complete,
1478 })
1479 }
1480
1481 async fn count_tokens(&self, text: &str, _model: &str) -> usize {
1482 text.len()
1483 }
1484
1485 fn max_context_tokens(&self, _model: &str) -> usize {
1486 8192
1487 }
1488
1489 fn name(&self) -> &str {
1490 "recording"
1491 }
1492
1493 fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
1494 leviath_providers::ModelCapabilities::default()
1495 }
1496 }
1497
1498 #[tokio::test]
1501 async fn run_test_case_renders_custom_region_through_its_script() {
1502 let manifest = r#"
1503[agent]
1504name = "custom-test-agent"
1505version = "0.1.0"
1506description = "test"
1507
1508[stages.main]
1509model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1510
1511[context.regions.task]
1512kind = "pinned"
1513max_tokens = 4000
1514
1515[context.regions.brain]
1516kind = "custom"
1517script = "hooks/brain.rhai"
1518max_tokens = 4000
1519"#;
1520 let blueprint = parse_manifest(manifest).unwrap();
1521 let scripts = std::collections::HashMap::from([(
1522 "hooks/brain.rhai".to_string(),
1523 std::sync::Arc::new(
1524 leviath_scripting::region_hook::compile(
1525 "hooks/brain.rhai",
1526 "fn render(ctx) { `<brain stage=${ctx.stage_name} model=${ctx.model}>` }",
1527 )
1528 .unwrap(),
1529 ),
1530 )]);
1531 let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
1532 let mut registry = ProviderRegistry::new();
1533 registry.register(
1534 "anthropic".to_string(),
1535 Arc::new(RecordingProvider { seen: seen.clone() }),
1536 );
1537 let tc = TestCase {
1538 name: "custom_render".to_string(),
1539 input: "hi".to_string(),
1540 expect_contains: Some("recorded".to_string()),
1541 expect_tool_call: None,
1542 max_tokens: None,
1543 };
1544 let passed = run_test_case(&blueprint, ®istry, &tc, &scripts)
1545 .await
1546 .unwrap();
1547 assert!(passed);
1548 let request = seen.lock().unwrap().take().expect("provider saw a request");
1549 let system_texts: Vec<&String> = request.system.iter().map(|b| &b.text).collect();
1551 let rendered = system_texts
1552 .iter()
1553 .any(|t| t.as_str() == "<brain stage=main model=claude-sonnet-4-6>");
1554 assert!(
1555 rendered,
1556 "custom region rendered with stage metadata; system blocks: {system_texts:?}"
1557 );
1558
1559 let provider = registry.get("anthropic").unwrap();
1561 assert_eq!(provider.count_tokens("abcd", "m").await, 4);
1562 assert_eq!(provider.max_context_tokens("m"), 8192);
1563 assert_eq!(provider.name(), "recording");
1564 }
1565
1566 #[tokio::test]
1569 async fn execute_fails_fast_on_a_broken_custom_region_script() {
1570 let dir = tempfile::tempdir().unwrap();
1571 let project = dir.path();
1572 std::fs::write(
1573 project.join("agent.leviath"),
1574 r#"
1575[agent]
1576name = "broken-custom"
1577version = "0.1.0"
1578description = "d"
1579
1580[stages.main]
1581model = { provider = "anthropic", model = "m" }
1582
1583[context.regions.brain]
1584kind = "custom"
1585script = "hooks/missing.rhai"
1586max_tokens = 4000
1587"#,
1588 )
1589 .unwrap();
1590 std::fs::create_dir(project.join("tests")).unwrap();
1591 std::fs::write(
1592 project.join("tests/basic.toml"),
1593 "[[test]]\nname = \"t\"\ninput = \"hi\"\n",
1594 )
1595 .unwrap();
1596 let args = TestArgs {
1597 path: Some(project.to_str().unwrap().to_string()),
1598 filter: None,
1599 dry_run: true,
1600 };
1601 let err = execute(args).await.unwrap_err().to_string();
1602 assert!(err.contains("region 'brain'"), "{err}");
1603 assert!(err.contains("hooks/missing.rhai"), "{err}");
1604 }
1605
1606 #[tokio::test]
1611 async fn run_test_case_inference_error_propagates() {
1612 let blueprint = basic_blueprint();
1613 let mut registry = ProviderRegistry::new();
1614 registry.register("anthropic".to_string(), Arc::new(ErrorProvider));
1615 let tc = TestCase {
1616 name: "inference_error".to_string(),
1617 input: "hi".to_string(),
1618 expect_contains: Some("x".to_string()),
1619 expect_tool_call: None,
1620 max_tokens: None,
1621 };
1622 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1623 let err = result.unwrap_err().to_string();
1624 assert!(err.contains("Inference failed"));
1625 }
1626
1627 #[tokio::test]
1629 async fn run_test_case_blueprint_with_no_stages_errors() {
1630 use leviath_core::{Blueprint, layout::ContextLayout};
1631 let blueprint = Blueprint::new(
1632 "no-stages".to_string(),
1633 "test".to_string(),
1634 vec![],
1635 ContextLayout::new(vec![], 4096),
1636 );
1637 let registry = ProviderRegistry::new();
1638 let tc = TestCase {
1639 name: "no_stages".to_string(),
1640 input: "hi".to_string(),
1641 expect_contains: Some("x".to_string()),
1642 expect_tool_call: None,
1643 max_tokens: None,
1644 };
1645 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1646 let err = result.unwrap_err().to_string();
1647 assert!(err.contains("Blueprint has no stages"));
1648 }
1649
1650 #[tokio::test]
1653 async fn run_test_case_with_preexisting_tool_results_region() {
1654 let blueprint = blueprint_with_tool_results_region();
1655 let mut registry = ProviderRegistry::new();
1656 registry.register(
1657 "anthropic".to_string(),
1658 Arc::new(MockProvider {
1659 content: "hello world".to_string(),
1660 tool_calls: vec![],
1661 }),
1662 );
1663 let tc = TestCase {
1664 name: "has_tool_results_region".to_string(),
1665 input: "hi".to_string(),
1666 expect_contains: Some("world".to_string()),
1667 expect_tool_call: None,
1668 max_tokens: None,
1669 };
1670 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1671 assert!(result.unwrap());
1672 }
1673
1674 #[tokio::test]
1678 async fn execute_with_registry_manifest_unreadable_errors() {
1679 let dir = tempfile::tempdir().unwrap();
1680 let project = dir.path();
1681 std::fs::create_dir_all(project.join("agent.leviath")).unwrap();
1682 let tests_dir = project.join("tests");
1683 std::fs::create_dir_all(&tests_dir).unwrap();
1684 let args = TestArgs {
1685 path: Some(project.to_str().unwrap().to_string()),
1686 filter: None,
1687 dry_run: true,
1688 };
1689 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1690 assert!(result.is_err());
1691 }
1692
1693 #[tokio::test]
1698 async fn execute_with_registry_config_load_fails_errors() {
1699 let dir = tempfile::tempdir().unwrap();
1700 let project = dir.path();
1701 let manifest = r#"
1702[agent]
1703name = "test-agent"
1704version = "0.1.0"
1705description = "test"
1706
1707[stages.main]
1708model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1709"#;
1710 write_test_agent(project, manifest);
1711 let tests_dir = project.join("tests");
1712 std::fs::create_dir_all(&tests_dir).unwrap();
1713
1714 crate::config::with_isolated_config_path_async(
1716 "test-cmd-config-fail",
1717 |fake_dir| async move {
1718 let bad_config = fake_dir.join("config.toml");
1719 std::fs::write(&bad_config, "not valid toml {{{").unwrap();
1720
1721 let args = TestArgs {
1722 path: Some(project.to_str().unwrap().to_string()),
1723 filter: None,
1724 dry_run: false, };
1726 let result =
1727 execute_with_registry(args, Box::new(build_registry_from_config)).await;
1728 assert!(result.is_err());
1729 },
1730 )
1731 .await;
1732 }
1733
1734 #[tokio::test]
1737 async fn execute_with_registry_manifest_invalid_toml_errors() {
1738 let dir = tempfile::tempdir().unwrap();
1739 let project = dir.path();
1740 std::fs::write(project.join("agent.leviath"), "not valid toml {{{").unwrap();
1741 let tests_dir = project.join("tests");
1742 std::fs::create_dir_all(&tests_dir).unwrap();
1743 let args = TestArgs {
1744 path: Some(project.to_str().unwrap().to_string()),
1745 filter: None,
1746 dry_run: false,
1747 };
1748 let result =
1749 execute_with_registry(args, Box::new(mock_registry_builder("irrelevant", vec![])))
1750 .await;
1751 assert!(result.is_err());
1752 }
1753
1754 #[tokio::test]
1757 async fn execute_with_registry_tests_dir_unreadable_errors() {
1758 let dir = tempfile::tempdir().unwrap();
1759 let project = dir.path();
1760 let manifest = r#"
1761[agent]
1762name = "test-agent"
1763version = "0.1.0"
1764description = "test"
1765
1766[stages.main]
1767model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1768"#;
1769 write_test_agent(project, manifest);
1770 std::fs::write(project.join("tests"), "not a dir").unwrap();
1772 let args = TestArgs {
1773 path: Some(project.to_str().unwrap().to_string()),
1774 filter: None,
1775 dry_run: true,
1776 };
1777 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1778 assert!(result.is_err());
1779 }
1780
1781 #[tokio::test]
1785 async fn execute_with_registry_toml_unreadable_errors() {
1786 let dir = tempfile::tempdir().unwrap();
1787 let project = dir.path();
1788 let manifest = r#"
1789[agent]
1790name = "test-agent"
1791version = "0.1.0"
1792description = "test"
1793
1794[stages.main]
1795model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1796"#;
1797 write_test_agent(project, manifest);
1798 let tests_dir = project.join("tests");
1799 std::fs::create_dir_all(&tests_dir).unwrap();
1800 std::fs::create_dir_all(tests_dir.join("unreadable.toml")).unwrap();
1801 let args = TestArgs {
1802 path: Some(project.to_str().unwrap().to_string()),
1803 filter: None,
1804 dry_run: true,
1805 };
1806 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1807 assert!(result.is_err());
1808 }
1809
1810 #[tokio::test]
1814 async fn execute_with_registry_rhai_unreadable_errors() {
1815 let dir = tempfile::tempdir().unwrap();
1816 let project = dir.path();
1817 let manifest = r#"
1818[agent]
1819name = "test-agent"
1820version = "0.1.0"
1821description = "test"
1822
1823[stages.main]
1824model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1825"#;
1826 write_test_agent(project, manifest);
1827 let tests_dir = project.join("tests");
1828 std::fs::create_dir_all(&tests_dir).unwrap();
1829 std::fs::create_dir_all(tests_dir.join("unreadable.rhai")).unwrap();
1830 let args = TestArgs {
1831 path: Some(project.to_str().unwrap().to_string()),
1832 filter: None,
1833 dry_run: true,
1834 };
1835 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
1836 assert!(result.is_err());
1837 }
1838
1839 #[tokio::test]
1842 async fn run_test_case_with_no_pinned_region_still_runs() {
1843 use leviath_core::Blueprint;
1844 use leviath_core::layout::ContextLayout;
1845 let blueprint = Blueprint::new(
1846 "no-regions".to_string(),
1847 "test".to_string(),
1848 vec![leviath_core::Stage::new(
1849 "main".to_string(),
1850 leviath_core::blueprint::ModelConfig::new(
1851 "anthropic".to_string(),
1852 "claude-sonnet-4-6".to_string(),
1853 ),
1854 )],
1855 ContextLayout::new(vec![], 4096),
1856 );
1857 let mut registry = ProviderRegistry::new();
1858 registry.register(
1859 "anthropic".to_string(),
1860 Arc::new(MockProvider {
1861 content: "hello world".to_string(),
1862 tool_calls: vec![],
1863 }),
1864 );
1865 let tc = TestCase {
1866 name: "no_pinned".to_string(),
1867 input: "hi".to_string(),
1868 expect_contains: Some("world".to_string()),
1869 expect_tool_call: None,
1870 max_tokens: None,
1871 };
1872 assert!(
1873 run_test_case(&blueprint, ®istry, &tc, &Default::default())
1874 .await
1875 .unwrap()
1876 );
1877 }
1878
1879 #[tokio::test]
1880 async fn run_test_case_passes_with_expect_contains() {
1881 let blueprint = basic_blueprint();
1882 let mut registry = ProviderRegistry::new();
1883 registry.register(
1884 "anthropic".to_string(),
1885 Arc::new(MockProvider {
1886 content: "Hello, world!".to_string(),
1887 tool_calls: vec![],
1888 }),
1889 );
1890
1891 let tc = TestCase {
1892 name: "greeting".to_string(),
1893 input: "say hello".to_string(),
1894 expect_contains: Some("world".to_string()),
1895 expect_tool_call: None,
1896 max_tokens: None,
1897 };
1898
1899 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1900 assert!(result.unwrap());
1901 }
1902
1903 #[tokio::test]
1904 async fn run_test_case_fails_expect_contains_mismatch() {
1905 let blueprint = basic_blueprint();
1906 let mut registry = ProviderRegistry::new();
1907 registry.register(
1908 "anthropic".to_string(),
1909 Arc::new(MockProvider {
1910 content: "Goodbye".to_string(),
1911 tool_calls: vec![],
1912 }),
1913 );
1914
1915 let tc = TestCase {
1916 name: "greeting".to_string(),
1917 input: "say hello".to_string(),
1918 expect_contains: Some("world".to_string()),
1919 expect_tool_call: None,
1920 max_tokens: None,
1921 };
1922
1923 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1924 assert!(!result.unwrap());
1925 }
1926
1927 #[tokio::test]
1928 async fn run_test_case_passes_with_expect_tool_call() {
1929 let blueprint = basic_blueprint();
1930 let mut registry = ProviderRegistry::new();
1931 registry.register(
1932 "anthropic".to_string(),
1933 Arc::new(MockProvider {
1934 content: String::new(),
1935 tool_calls: vec![ToolCall {
1936 id: "call_1".to_string(),
1937 name: "bash".to_string(),
1938 arguments: serde_json::json!({}),
1939 thought_signature: None,
1940 }],
1941 }),
1942 );
1943
1944 let tc = TestCase {
1945 name: "tool_test".to_string(),
1946 input: "run a command".to_string(),
1947 expect_contains: None,
1948 expect_tool_call: Some("bash".to_string()),
1949 max_tokens: None,
1950 };
1951
1952 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1953 assert!(result.unwrap());
1954 }
1955
1956 #[tokio::test]
1957 async fn run_test_case_fails_expect_tool_call_missing() {
1958 let blueprint = basic_blueprint();
1959 let mut registry = ProviderRegistry::new();
1960 registry.register(
1961 "anthropic".to_string(),
1962 Arc::new(MockProvider {
1963 content: "no tools here".to_string(),
1964 tool_calls: vec![ToolCall {
1970 id: "call_1".to_string(),
1971 name: "write_file".to_string(),
1972 arguments: serde_json::json!({}),
1973 thought_signature: None,
1974 }],
1975 }),
1976 );
1977
1978 let tc = TestCase {
1979 name: "tool_test".to_string(),
1980 input: "run a command".to_string(),
1981 expect_contains: None,
1982 expect_tool_call: Some("bash".to_string()),
1983 max_tokens: None,
1984 };
1985
1986 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
1987 assert!(!result.unwrap());
1988 }
1989
1990 #[tokio::test]
1991 async fn run_test_case_fails_both_assertions() {
1992 let blueprint = basic_blueprint();
1993 let mut registry = ProviderRegistry::new();
1994 registry.register(
1995 "anthropic".to_string(),
1996 Arc::new(MockProvider {
1997 content: "unrelated content".to_string(),
1998 tool_calls: vec![],
1999 }),
2000 );
2001
2002 let tc = TestCase {
2003 name: "both".to_string(),
2004 input: "do stuff".to_string(),
2005 expect_contains: Some("expected".to_string()),
2006 expect_tool_call: Some("write_file".to_string()),
2007 max_tokens: None,
2008 };
2009
2010 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2011 assert!(!result.unwrap());
2012 }
2013
2014 #[tokio::test]
2015 async fn run_test_case_no_assertions_always_passes() {
2016 let blueprint = basic_blueprint();
2017 let mut registry = ProviderRegistry::new();
2018 registry.register(
2019 "anthropic".to_string(),
2020 Arc::new(MockProvider {
2021 content: "anything".to_string(),
2022 tool_calls: vec![],
2023 }),
2024 );
2025
2026 let tc = TestCase {
2027 name: "no_assertions".to_string(),
2028 input: "hi".to_string(),
2029 expect_contains: None,
2030 expect_tool_call: None,
2031 max_tokens: None,
2032 };
2033
2034 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2035 assert!(result.unwrap());
2036 }
2037
2038 #[tokio::test]
2039 async fn run_test_case_provider_not_registered_errors() {
2040 let blueprint = basic_blueprint();
2041 let registry = ProviderRegistry::new(); let tc = TestCase {
2044 name: "no_provider".to_string(),
2045 input: "hi".to_string(),
2046 expect_contains: Some("x".to_string()),
2047 expect_tool_call: None,
2048 max_tokens: None,
2049 };
2050
2051 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2052 let err = result.unwrap_err().to_string();
2053 assert!(err.contains("not configured"));
2054 }
2055
2056 #[tokio::test]
2057 async fn no_temperature_provider_metadata_is_exercised() {
2058 let p = NoTemperatureProvider;
2059 assert_eq!(p.name(), "no-temperature");
2060 assert_eq!(p.count_tokens("abcd", "m").await, 4);
2061 assert_eq!(p.max_context_tokens("m"), 8192);
2062 }
2063
2064 #[tokio::test]
2065 async fn run_test_case_omits_temperature_when_provider_lacks_it() {
2066 let blueprint = basic_blueprint();
2067 let mut registry = ProviderRegistry::new();
2068 registry.register("anthropic".to_string(), Arc::new(NoTemperatureProvider));
2069 let tc = TestCase {
2070 name: "no_temp".to_string(),
2071 input: "hi".to_string(),
2072 expect_contains: Some("cold".to_string()),
2073 expect_tool_call: None,
2074 max_tokens: None,
2075 };
2076 assert!(
2077 run_test_case(&blueprint, ®istry, &tc, &Default::default())
2078 .await
2079 .unwrap()
2080 );
2081 }
2082
2083 #[tokio::test]
2084 async fn run_test_case_long_input_runs() {
2085 let blueprint = basic_blueprint();
2087 let mut registry = ProviderRegistry::new();
2088 registry.register(
2089 "anthropic".to_string(),
2090 Arc::new(MockProvider {
2091 content: "response text mentioning keyword".to_string(),
2092 tool_calls: vec![],
2093 }),
2094 );
2095
2096 let tc = TestCase {
2097 name: "long_input".to_string(),
2098 input: "x".repeat(500),
2099 expect_contains: Some("keyword".to_string()),
2100 expect_tool_call: None,
2101 max_tokens: None,
2102 };
2103
2104 let result = run_test_case(&blueprint, ®istry, &tc, &Default::default()).await;
2105 assert!(result.unwrap());
2106 }
2107
2108 fn mock_registry_builder(
2129 content: &'static str,
2130 tool_calls: Vec<ToolCall>,
2131 ) -> impl FnOnce(&Config) -> Result<ProviderRegistry, leviath_providers::ProviderError> {
2132 move |_config: &Config| {
2133 let mut reg = ProviderRegistry::new();
2134 reg.register(
2135 "anthropic".to_string(),
2136 Arc::new(MockProvider {
2137 content: content.to_string(),
2138 tool_calls,
2139 }),
2140 );
2141 Ok(reg)
2142 }
2143 }
2144
2145 fn write_project_with_test_file(project: &std::path::Path, test_toml: &str) {
2146 let manifest = r#"
2147[agent]
2148name = "test-agent"
2149version = "0.1.0"
2150description = "test"
2151
2152[stages.main]
2153model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2154"#;
2155 write_test_agent(project, manifest);
2156 let tests_dir = project.join("tests");
2157 std::fs::create_dir_all(&tests_dir).unwrap();
2158 std::fs::write(tests_dir.join("basic.toml"), test_toml).unwrap();
2159 }
2160
2161 #[tokio::test]
2162 async fn execute_with_registry_non_dry_run_all_pass() {
2163 crate::config::with_isolated_config_path_async(
2164 "test-rs-non-dry-run-all-pass",
2165 |_fake_dir| async move {
2166 let dir = tempfile::tempdir().unwrap();
2167 let project = dir.path();
2168 write_project_with_test_file(
2169 project,
2170 r#"
2171[[test]]
2172name = "greeting"
2173input = "say hello"
2174expect_contains = "world"
2175"#,
2176 );
2177
2178 let args = TestArgs {
2179 path: Some(project.to_str().unwrap().to_string()),
2180 filter: None,
2181 dry_run: false,
2182 };
2183
2184 let result = with_tracing(|| {
2185 execute_with_registry(
2186 args,
2187 Box::new(mock_registry_builder("Hello, world!", vec![])),
2188 )
2189 })
2190 .await;
2191 assert!(result.is_ok());
2192 },
2193 )
2194 .await;
2195 }
2196
2197 #[tokio::test]
2198 async fn execute_with_registry_none_path_defaults_to_current_dir() {
2199 let args = TestArgs {
2206 path: None,
2207 filter: None,
2208 dry_run: true,
2209 };
2210 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2211 assert!(result.is_err());
2212 assert!(
2213 result
2214 .unwrap_err()
2215 .to_string()
2216 .contains("No agent.leviath found")
2217 );
2218 }
2219
2220 #[tokio::test]
2221 async fn execute_with_registry_non_dry_run_failure_bails_with_count() {
2222 crate::config::with_isolated_config_path_async(
2223 "test-rs-non-dry-run-failure-bails-with-count",
2224 |_fake_dir| async move {
2225 let dir = tempfile::tempdir().unwrap();
2226 let project = dir.path();
2227 write_project_with_test_file(
2228 project,
2229 r#"
2230[[test]]
2231name = "greeting"
2232input = "say hello"
2233expect_contains = "world"
2234"#,
2235 );
2236
2237 let args = TestArgs {
2238 path: Some(project.to_str().unwrap().to_string()),
2239 filter: None,
2240 dry_run: false,
2241 };
2242
2243 let result =
2244 execute_with_registry(args, Box::new(mock_registry_builder("goodbye", vec![])))
2245 .await;
2246 let err = result.unwrap_err().to_string();
2247 assert!(err.contains("1 test(s) failed"));
2248 },
2249 )
2250 .await;
2251 }
2252
2253 #[tokio::test]
2254 async fn execute_with_registry_non_dry_run_applies_filter() {
2255 crate::config::with_isolated_config_path_async(
2256 "test-rs-non-dry-run-applies-filter",
2257 |_fake_dir| async move {
2258 let dir = tempfile::tempdir().unwrap();
2259 let project = dir.path();
2260 write_project_with_test_file(
2261 project,
2262 r#"
2263[[test]]
2264name = "keep_me"
2265input = "say hello"
2266expect_contains = "world"
2267
2268[[test]]
2269name = "skip_me"
2270input = "say hello"
2271expect_contains = "unmatchable content"
2272"#,
2273 );
2274
2275 let args = TestArgs {
2276 path: Some(project.to_str().unwrap().to_string()),
2277 filter: Some("keep".to_string()),
2278 dry_run: false,
2279 };
2280
2281 let result = execute_with_registry(
2285 args,
2286 Box::new(mock_registry_builder("Hello, world!", vec![])),
2287 )
2288 .await;
2289 assert!(result.is_ok());
2290 },
2291 )
2292 .await;
2293 }
2294
2295 #[tokio::test]
2296 async fn execute_with_registry_non_dry_run_tool_call_assertion() {
2297 crate::config::with_isolated_config_path_async(
2298 "test-rs-non-dry-run-tool-call-assertion",
2299 |_fake_dir| async move {
2300 let dir = tempfile::tempdir().unwrap();
2301 let project = dir.path();
2302 write_project_with_test_file(
2303 project,
2304 r#"
2305[[test]]
2306name = "tool_test"
2307input = "run a command"
2308expect_tool_call = "bash"
2309"#,
2310 );
2311
2312 let args = TestArgs {
2313 path: Some(project.to_str().unwrap().to_string()),
2314 filter: None,
2315 dry_run: false,
2316 };
2317
2318 let tool_calls = vec![ToolCall {
2319 id: "call_1".to_string(),
2320 name: "bash".to_string(),
2321 arguments: serde_json::json!({}),
2322 thought_signature: None,
2323 }];
2324 let result =
2325 execute_with_registry(args, Box::new(mock_registry_builder("", tool_calls)))
2326 .await;
2327 assert!(result.is_ok());
2328 },
2329 )
2330 .await;
2331 }
2332
2333 #[tokio::test]
2334 async fn execute_with_registry_non_dry_run_provider_error_counts_as_failure() {
2335 crate::config::with_isolated_config_path_async(
2336 "test-rs-non-dry-run-provider-error-counts-as-failure",
2337 |_fake_dir| async move {
2338 let dir = tempfile::tempdir().unwrap();
2339 let project = dir.path();
2340 write_project_with_test_file(
2341 project,
2342 r#"
2343[[test]]
2344name = "no_such_provider"
2345input = "hi"
2346expect_contains = "x"
2347"#,
2348 );
2349 std::fs::write(
2353 project.join("agent.leviath"),
2354 r#"
2355[agent]
2356name = "test-agent"
2357version = "0.1.0"
2358description = "test"
2359
2360[stages.main]
2361model = { provider = "nonexistent-provider", model = "x" }
2362"#,
2363 )
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 = execute_with_registry(
2373 args,
2374 Box::new(mock_registry_builder("irrelevant", vec![])),
2375 )
2376 .await;
2377 let err = result.unwrap_err().to_string();
2378 assert!(err.contains("1 test(s) failed"));
2379 },
2380 )
2381 .await;
2382 }
2383
2384 #[tokio::test]
2385 async fn execute_with_registry_non_dry_run_toml_malformed_errors() {
2386 crate::config::with_isolated_config_path_async(
2387 "test-rs-non-dry-run-toml-malformed-errors",
2388 |_fake_dir| async move {
2389 let dir = tempfile::tempdir().unwrap();
2390 let project = dir.path();
2391 write_project_with_test_file(project, "not valid {{{ toml");
2392
2393 let args = TestArgs {
2394 path: Some(project.to_str().unwrap().to_string()),
2395 filter: None,
2396 dry_run: false,
2397 };
2398
2399 let result = execute_with_registry(
2400 args,
2401 Box::new(mock_registry_builder("irrelevant", vec![])),
2402 )
2403 .await;
2404 assert!(result.is_err());
2405 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
2406 },
2407 )
2408 .await;
2409 }
2410
2411 #[tokio::test]
2414 async fn execute_with_registry_rhai_script_passes() {
2415 crate::config::with_isolated_config_path_async(
2416 "test-rs-rhai-script-passes",
2417 |_fake_dir| async move {
2418 let dir = tempfile::tempdir().unwrap();
2419 let project = dir.path();
2420 let manifest = r#"
2421[agent]
2422name = "test-agent"
2423version = "0.1.0"
2424description = "test"
2425
2426[stages.main]
2427model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2428"#;
2429 write_test_agent(project, manifest);
2430 let tests_dir = project.join("tests");
2431 std::fs::create_dir_all(&tests_dir).unwrap();
2432 std::fs::write(tests_dir.join("script.rhai"), "true").unwrap();
2433
2434 let args = TestArgs {
2435 path: Some(project.to_str().unwrap().to_string()),
2436 filter: None,
2437 dry_run: false,
2438 };
2439
2440 let result =
2441 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2442 .await;
2443 assert!(result.is_ok());
2444 },
2445 )
2446 .await;
2447 }
2448
2449 #[tokio::test]
2450 async fn execute_with_registry_rhai_script_returns_false_fails() {
2451 crate::config::with_isolated_config_path_async(
2452 "test-rs-rhai-script-returns-false-fails",
2453 |_fake_dir| async move {
2454 let dir = tempfile::tempdir().unwrap();
2455 let project = dir.path();
2456 let manifest = r#"
2457[agent]
2458name = "test-agent"
2459version = "0.1.0"
2460description = "test"
2461
2462[stages.main]
2463model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2464"#;
2465 write_test_agent(project, manifest);
2466 let tests_dir = project.join("tests");
2467 std::fs::create_dir_all(&tests_dir).unwrap();
2468 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2469
2470 let args = TestArgs {
2471 path: Some(project.to_str().unwrap().to_string()),
2472 filter: None,
2473 dry_run: false,
2474 };
2475
2476 let result =
2477 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2478 .await;
2479 let err = result.unwrap_err().to_string();
2480 assert!(err.contains("1 test(s) failed"));
2481 },
2482 )
2483 .await;
2484 }
2485
2486 #[tokio::test]
2487 async fn execute_with_registry_rhai_script_error_fails() {
2488 crate::config::with_isolated_config_path_async(
2489 "test-rs-rhai-script-error-fails",
2490 |_fake_dir| async move {
2491 let dir = tempfile::tempdir().unwrap();
2492 let project = dir.path();
2493 let manifest = r#"
2494[agent]
2495name = "test-agent"
2496version = "0.1.0"
2497description = "test"
2498
2499[stages.main]
2500model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2501"#;
2502 write_test_agent(project, manifest);
2503 let tests_dir = project.join("tests");
2504 std::fs::create_dir_all(&tests_dir).unwrap();
2505 std::fs::write(tests_dir.join("script.rhai"), "this is not valid rhai (((")
2506 .unwrap();
2507
2508 let args = TestArgs {
2509 path: Some(project.to_str().unwrap().to_string()),
2510 filter: None,
2511 dry_run: false,
2512 };
2513
2514 let result =
2515 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2516 .await;
2517 assert!(result.is_err());
2518 },
2519 )
2520 .await;
2521 }
2522
2523 #[tokio::test]
2524 async fn execute_with_registry_rhai_script_non_bool_return_passes() {
2525 crate::config::with_isolated_config_path_async(
2526 "test-rs-rhai-script-non-bool-return-passes",
2527 |_fake_dir| async move {
2528 let dir = tempfile::tempdir().unwrap();
2529 let project = dir.path();
2530 let manifest = r#"
2531[agent]
2532name = "test-agent"
2533version = "0.1.0"
2534description = "test"
2535
2536[stages.main]
2537model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2538"#;
2539 write_test_agent(project, manifest);
2540 let tests_dir = project.join("tests");
2541 std::fs::create_dir_all(&tests_dir).unwrap();
2542 std::fs::write(tests_dir.join("script.rhai"), "42").unwrap();
2545
2546 let args = TestArgs {
2547 path: Some(project.to_str().unwrap().to_string()),
2548 filter: None,
2549 dry_run: false,
2550 };
2551
2552 let result =
2553 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2554 .await;
2555 assert!(result.is_ok());
2556 },
2557 )
2558 .await;
2559 }
2560
2561 #[tokio::test]
2562 async fn execute_with_registry_rhai_script_filter_excludes_all() {
2563 crate::config::with_isolated_config_path_async(
2564 "test-rs-rhai-script-filter-excludes-all",
2565 |_fake_dir| async move {
2566 let dir = tempfile::tempdir().unwrap();
2567 let project = dir.path();
2568 let manifest = r#"
2569[agent]
2570name = "test-agent"
2571version = "0.1.0"
2572description = "test"
2573
2574[stages.main]
2575model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2576"#;
2577 write_test_agent(project, manifest);
2578 let tests_dir = project.join("tests");
2579 std::fs::create_dir_all(&tests_dir).unwrap();
2580 std::fs::write(tests_dir.join("script.rhai"), "false").unwrap();
2581
2582 let args = TestArgs {
2583 path: Some(project.to_str().unwrap().to_string()),
2584 filter: Some("no-such-script".to_string()),
2585 dry_run: false,
2586 };
2587
2588 let result =
2591 execute_with_registry(args, Box::new(mock_registry_builder("unused", vec![])))
2592 .await;
2593 assert!(result.is_ok());
2594 },
2595 )
2596 .await;
2597 }
2598
2599 #[test]
2607 fn build_registry_from_config_registers_all_providers() {
2608 let config = Config {
2609 default_provider: "anthropic".to_string(),
2610 providers: crate::config::ProviderConfig {
2611 anthropic_api_key: Some("fake-anthropic-key".to_string()),
2612 openai_api_key: Some("fake-openai-key".to_string()),
2613 google_api_key: Some("fake-google-key".to_string()),
2614 claude_code_enabled: false,
2615 claude_code_binary: None,
2616 claude_code_effort: None,
2617 anthropic_cache_ttl: None,
2618 fallback_order: Vec::new(),
2619 },
2620 openrouter_api_key: Some("fake-openrouter-key".to_string()),
2621 ollama_base_url: Some("http://localhost:12345".to_string()),
2622 ..Config::default()
2623 };
2624
2625 let registry =
2626 build_registry_from_config(&config).expect("an HTTPS client builds in tests");
2627 assert!(registry.has("anthropic"));
2628 assert!(registry.has("openai"));
2629 assert!(registry.has("google"));
2630 assert!(registry.has("openrouter"));
2631 assert!(registry.has("ollama"));
2632 }
2633
2634 #[test]
2635 fn build_registry_from_config_no_keys_still_registers_ollama_with_default_url() {
2636 let config = Config::default();
2637 let registry =
2638 build_registry_from_config(&config).expect("an HTTPS client builds in tests");
2639 assert!(!registry.has("anthropic"));
2640 assert!(!registry.has("openai"));
2641 assert!(!registry.has("google"));
2642 assert!(!registry.has("openrouter"));
2643 assert!(registry.has("ollama"));
2646 }
2647
2648 #[tokio::test]
2651 async fn execute_with_registry_ignores_non_test_files_in_tests_dir() {
2652 let dir = tempfile::tempdir().unwrap();
2653 let project = dir.path();
2654 let manifest = r#"
2655[agent]
2656name = "test-agent"
2657version = "0.1.0"
2658description = "test"
2659
2660[stages.main]
2661model = { provider = "anthropic", model = "claude-sonnet-4-6" }
2662"#;
2663 write_test_agent(project, manifest);
2664 let tests_dir = project.join("tests");
2665 std::fs::create_dir_all(&tests_dir).unwrap();
2666 std::fs::write(tests_dir.join("readme.txt"), "this file should be ignored").unwrap();
2669 let args = TestArgs {
2670 path: Some(project.to_str().unwrap().to_string()),
2671 filter: None,
2672 dry_run: true,
2673 };
2674 let result = execute_with_registry(args, Box::new(build_registry_from_config)).await;
2675 assert!(result.is_ok());
2676 }
2677
2678 #[tokio::test]
2681 async fn error_provider_trivial_trait_methods() {
2682 let provider = ErrorProvider;
2683 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2684 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2685 assert_eq!(provider.name(), "error-provider");
2686 let caps = provider.capabilities("any-model");
2687 let _ = caps; }
2689
2690 #[tokio::test]
2693 async fn mock_provider_trivial_trait_methods() {
2694 let provider = MockProvider {
2695 content: "x".to_string(),
2696 tool_calls: vec![],
2697 };
2698 assert_eq!(provider.count_tokens("hello", "any-model").await, 5);
2699 assert_eq!(provider.max_context_tokens("any-model"), 8192);
2700 assert_eq!(provider.name(), "mock");
2701 }
2702
2703 #[test]
2704 fn a_registry_needs_an_https_client_it_can_build() {
2705 let mut config = Config::default();
2708 config.providers.anthropic_api_key = Some("k".to_string());
2709 let err = build_registry_from_config_with(&config, &|_t| {
2710 Err(leviath_providers::provider::malformed_url_error())
2711 })
2712 .err()
2713 .expect("a failing client factory should fail the registry");
2714 assert!(err.to_string().contains("root certificate store"));
2715 }
2716
2717 #[tokio::test]
2718 async fn a_real_run_stops_when_the_registry_will_not_build() {
2719 crate::config::with_isolated_config_path_async(
2722 "test-a_real_run_stops_when_the_registry_will_not_build",
2723 |_fake_dir| async move {
2724 let dir = tempfile::tempdir().expect("tempdir");
2725 let project = dir.path();
2726 write_project_with_test_file(project, "[[test]]\nname = \"t\"\ninput = \"hi\"\n");
2727 let args = TestArgs {
2728 path: Some(project.to_str().expect("utf-8 path").to_string()),
2729 filter: None,
2730 dry_run: false,
2731 };
2732 let failing: RegistryBuilder = Box::new(|_config: &Config| {
2733 Err(leviath_providers::ProviderError::ClientBuild(
2734 "no roots".to_string(),
2735 ))
2736 });
2737 let err = execute_with_registry(args, failing)
2738 .await
2739 .expect_err("a failing registry builder should stop the run");
2740 assert!(err.to_string().contains("no roots"), "{err}");
2741 },
2742 )
2743 .await;
2744 }
2745}