1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use crate::test_toml::{StepDef, TestToml};
6
7#[derive(Debug, Clone)]
9pub enum DiscoveredTest {
10 Simple {
12 name: String,
13 source: PathBuf,
16 setup: SetupConfig,
17 tests: Vec<TestEntry>,
18 browser: bool,
19 ram_override: Option<u32>,
20 },
21 Lifecycle {
23 name: String,
24 source: PathBuf,
25 steps: Vec<StepDef>,
26 browser: bool,
27 ram_override: Option<u32>,
28 },
29}
30
31#[derive(Debug, Clone, Default)]
32pub struct SetupConfig {
33 pub services: Vec<String>,
34 pub quadlets: Vec<String>,
35 pub quadlet_dir: Option<PathBuf>,
36}
37
38impl DiscoveredTest {
39 pub fn name(&self) -> &str {
40 match self {
41 DiscoveredTest::Simple { name, .. } => name,
42 DiscoveredTest::Lifecycle { name, .. } => name,
43 }
44 }
45
46 pub fn source(&self) -> &Path {
50 match self {
51 DiscoveredTest::Simple { source, .. } => source,
52 DiscoveredTest::Lifecycle { source, .. } => source,
53 }
54 }
55
56 pub fn step_kinds(&self) -> Vec<&'static str> {
60 let mut kinds: Vec<&'static str> = Vec::new();
61 let push = |k: &'static str, v: &mut Vec<&'static str>| {
62 if !v.contains(&k) {
63 v.push(k);
64 }
65 };
66 match self {
67 DiscoveredTest::Lifecycle { steps, .. } => {
68 for step in steps {
69 let kind = match step {
70 StepDef::Add { .. } => "add",
71 StepDef::Remove { .. } => "remove",
72 StepDef::Wait { .. } => "wait",
73 StepDef::Shell { .. } => "shell",
74 StepDef::Http { .. } => "http",
75 StepDef::Playwright { .. } => "playwright",
76 StepDef::Mail { .. } => "mail",
77 };
78 push(kind, &mut kinds);
79 }
80 }
81 DiscoveredTest::Simple { setup, tests, .. } => {
82 if !setup.services.is_empty() || !setup.quadlets.is_empty() {
83 push("setup", &mut kinds);
84 }
85 if !tests.is_empty() {
86 push("shell", &mut kinds);
87 }
88 }
89 }
90 kinds
91 }
92
93 pub fn services(&self) -> Vec<&str> {
95 match self {
96 DiscoveredTest::Simple { setup, .. } => {
97 setup.services.iter().map(|s| s.as_str()).collect()
98 }
99 DiscoveredTest::Lifecycle { steps, .. } => {
100 let mut svcs = Vec::new();
101 for step in steps {
102 if let StepDef::Add { service, .. } = step
103 && !svcs.contains(&service.as_str())
104 {
105 svcs.push(service.as_str());
106 }
107 }
108 svcs
109 }
110 }
111 }
112
113 pub fn tests(&self) -> &[TestEntry] {
114 match self {
115 DiscoveredTest::Simple { tests, .. } => tests,
116 DiscoveredTest::Lifecycle { .. } => &[],
117 }
118 }
119
120 pub fn test_count(&self) -> usize {
121 match self {
122 DiscoveredTest::Simple { tests, .. } => tests.len(),
123 DiscoveredTest::Lifecycle { steps, .. } => steps.len(),
124 }
125 }
126
127 #[allow(dead_code)]
128 pub fn summary(&self) -> String {
129 match self {
130 DiscoveredTest::Simple { name, setup, .. } => {
131 if setup.services.is_empty() {
132 name.clone()
133 } else {
134 format!("{} ({})", name, setup.services.join(" + "))
135 }
136 }
137 DiscoveredTest::Lifecycle { name, steps, .. } => {
138 format!("{} ({} steps)", name, steps.len())
139 }
140 }
141 }
142
143 pub fn is_lifecycle(&self) -> bool {
144 matches!(self, DiscoveredTest::Lifecycle { .. })
145 }
146
147 pub fn has_quadlets(&self) -> bool {
148 match self {
149 DiscoveredTest::Simple { setup, .. } => !setup.quadlets.is_empty(),
150 DiscoveredTest::Lifecycle { .. } => false,
151 }
152 }
153
154 pub fn needs_browser(&self) -> bool {
155 match self {
156 DiscoveredTest::Simple { browser, .. } => *browser,
157 DiscoveredTest::Lifecycle { browser, .. } => *browser,
158 }
159 }
160
161 pub fn ram_override(&self) -> Option<u32> {
162 match self {
163 DiscoveredTest::Simple { ram_override, .. } => *ram_override,
164 DiscoveredTest::Lifecycle { ram_override, .. } => *ram_override,
165 }
166 }
167}
168
169#[allow(dead_code)]
170#[derive(Debug, Clone)]
171pub struct TestEntry {
172 pub name: String,
173 pub run: String,
174 pub timeout_secs: u64,
175 pub env: std::collections::BTreeMap<String, String>,
177}
178
179pub fn discover_local_project(project_dir: &Path) -> Result<Option<DiscoveredTest>> {
183 let test_toml_path = project_dir.join("test.toml");
184 if !test_toml_path.exists() {
185 return Ok(None);
186 }
187
188 let parsed = TestToml::parse(&test_toml_path)?;
189
190 let quadlet_extensions = ["container", "volume", "network", "pod", "kube"];
192 let mut quadlet_files = Vec::new();
193 let entries = std::fs::read_dir(project_dir)
194 .with_context(|| format!("failed to read {}", project_dir.display()))?;
195 for entry in entries {
196 let entry = entry?;
197 let path = entry.path();
198 if let Some(ext) = path.extension().and_then(|e| e.to_str())
199 && quadlet_extensions.contains(&ext)
200 && let Some(name) = path.file_name().and_then(|n| n.to_str())
201 {
202 quadlet_files.push(name.to_string());
203 }
204 }
205
206 if quadlet_files.is_empty() {
207 anyhow::bail!(
208 "test.toml found at {} but no quadlet files (.container, .volume, .network, .pod) in the same directory",
209 test_toml_path.display()
210 );
211 }
212
213 let project_dir = std::fs::canonicalize(project_dir)
214 .with_context(|| format!("failed to canonicalize {}", project_dir.display()))?;
215
216 let dir_name = project_dir
218 .file_name()
219 .and_then(|n| n.to_str())
220 .unwrap_or("project")
221 .to_string();
222
223 let mut tests =
224 discover_from_test_toml(&test_toml_path, &parsed, &dir_name, Some(&project_dir))?;
225
226 if tests.len() != 1 {
230 anyhow::bail!(
231 "local project test.toml must describe exactly one test (got {}); \
232 multi-test [[tests]] arrays are only supported inside the registry",
233 tests.len()
234 );
235 }
236 let mut test = tests.remove(0);
237
238 if let DiscoveredTest::Simple { ref mut setup, .. } = test
240 && setup.quadlets.is_empty()
241 && !quadlet_files.is_empty()
242 {
243 setup.quadlets = quadlet_files;
244 }
245
246 Ok(Some(test))
247}
248
249pub fn discover(registry_path: &Path) -> Result<Vec<DiscoveredTest>> {
254 let mut discovered = Vec::new();
255
256 let entries = std::fs::read_dir(registry_path)
258 .with_context(|| format!("failed to read registry at {}", registry_path.display()))?;
259
260 for entry in entries {
261 let entry = entry?;
262 let path = entry.path();
263
264 if !path.is_dir() {
266 continue;
267 }
268 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
269 Some(n) => n.to_string(),
270 None => continue,
271 };
272 if dir_name == "tests" || dir_name.starts_with('.') {
273 continue;
274 }
275
276 let test_toml_path = path.join("test.toml");
277 if !test_toml_path.exists() {
278 continue;
279 }
280
281 match TestToml::parse(&test_toml_path) {
282 Ok(parsed) => {
283 match discover_from_test_toml(&test_toml_path, &parsed, &dir_name, None) {
284 Ok(tests) => discovered.extend(tests),
285 Err(e) => {
286 eprintln!(
287 "warning: failed to process {}: {e}",
288 test_toml_path.display()
289 );
290 }
291 }
292 }
293 Err(e) => {
294 eprintln!("warning: failed to parse {}: {e}", test_toml_path.display());
295 }
296 }
297 }
298
299 let tests_dir = registry_path.join("tests");
301 if tests_dir.is_dir() {
302 let entries = std::fs::read_dir(&tests_dir)
303 .with_context(|| format!("failed to read {}", tests_dir.display()))?;
304
305 for entry in entries {
306 let entry = entry?;
307 let path = entry.path();
308
309 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
310 continue;
311 }
312
313 let file_stem = path
314 .file_stem()
315 .and_then(|s| s.to_str())
316 .unwrap_or("unknown")
317 .to_string();
318
319 match TestToml::parse(&path) {
320 Ok(parsed) => match discover_from_test_toml(&path, &parsed, &file_stem, None) {
321 Ok(tests) => discovered.extend(tests),
322 Err(e) => {
323 eprintln!("warning: failed to process {}: {e}", path.display());
324 }
325 },
326 Err(e) => {
327 eprintln!("warning: failed to parse {}: {e}", path.display());
328 }
329 }
330 }
331 }
332
333 discovered.sort_by(|a, b| a.name().cmp(b.name()));
335
336 Ok(discovered)
337}
338
339fn discover_from_test_toml(
357 path: &Path,
358 parsed: &TestToml,
359 service_name_hint: &str,
360 quadlet_dir: Option<&Path>,
361) -> Result<Vec<DiscoveredTest>> {
362 let new_format_tests: Vec<&crate::test_toml::TestDef> = parsed
364 .tests
365 .iter()
366 .filter(|t| !t.steps.is_empty())
367 .collect();
368 if !new_format_tests.is_empty() {
369 let is_service_owned = path
375 .parent()
376 .and_then(|p| p.file_name())
377 .and_then(|n| n.to_str())
378 == Some(service_name_hint);
379
380 let mut out = Vec::with_capacity(new_format_tests.len());
381 for t in new_format_tests {
382 let qualified = if is_service_owned && t.name != service_name_hint {
383 format!("{service_name_hint}-{}", t.name)
384 } else {
385 t.name.clone()
386 };
387 out.push(DiscoveredTest::Lifecycle {
388 name: qualified,
389 source: path.to_path_buf(),
390 steps: t.steps.clone(),
391 browser: t.browser || parsed.needs_browser(),
392 ram_override: t.ram.or(parsed.ram_override()),
393 });
394 }
395 return Ok(out);
396 }
397
398 let name = parsed.name_or_default(path);
400 let test_name = if parsed.test.as_ref().and_then(|t| t.name.as_ref()).is_some() {
401 name
402 } else {
403 service_name_hint.to_string()
404 };
405 let browser = parsed.needs_browser();
406 let ram_override = parsed.ram_override();
407
408 if parsed.is_lifecycle() {
409 return Ok(vec![DiscoveredTest::Lifecycle {
410 name: test_name,
411 source: path.to_path_buf(),
412 steps: parsed.steps.clone(),
413 browser,
414 ram_override,
415 }]);
416 }
417
418 let setup = match &parsed.setup {
420 Some(s) => SetupConfig {
421 services: s.services.clone(),
422 quadlets: s.quadlets.clone(),
423 quadlet_dir: quadlet_dir.map(PathBuf::from),
424 },
425 None => SetupConfig {
426 services: vec![service_name_hint.to_string()],
427 quadlets: Vec::new(),
428 quadlet_dir: quadlet_dir.map(PathBuf::from),
429 },
430 };
431
432 let tests = parsed
433 .tests
434 .iter()
435 .map(|t| TestEntry {
436 name: t.name.clone(),
437 run: t.run.clone().unwrap_or_default(),
438 timeout_secs: t.timeout,
439 env: t.env.clone(),
440 })
441 .collect();
442
443 Ok(vec![DiscoveredTest::Simple {
444 name: test_name,
445 source: path.to_path_buf(),
446 setup,
447 tests,
448 browser,
449 ram_override,
450 }])
451}
452
453pub fn service_recommended_ram(registry_path: &Path, service_name: &str) -> Result<Option<u64>> {
455 let service_toml = registry_path.join(service_name).join("service.toml");
456 if !service_toml.exists() {
457 return Ok(None);
458 }
459 let content = std::fs::read_to_string(&service_toml)
460 .with_context(|| format!("failed to read {}", service_toml.display()))?;
461 let parsed: ServiceTomlRam = toml::from_str(&content)
462 .with_context(|| format!("failed to parse {}", service_toml.display()))?;
463 Ok(parsed.requirements.and_then(|r| r.ram.recommended))
464}
465
466pub fn vm_memory_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
470 if let Some(ram) = test.ram_override() {
471 return ram;
472 }
473
474 let services: Vec<&str> = match test {
475 DiscoveredTest::Lifecycle { steps, .. } => steps
476 .iter()
477 .filter_map(|s| match s {
478 StepDef::Add { service, .. } => Some(service.as_str()),
479 StepDef::Shell { run, .. } => run
481 .split_whitespace()
482 .collect::<Vec<_>>()
483 .windows(3)
484 .find(|w| w[0] == "ryra" && w[1] == "add")
485 .map(|w| w[2]),
486 _ => None,
487 })
488 .collect(),
489 _ => test.services(),
490 };
491
492 let service_ram: u64 = services
493 .iter()
494 .map(|svc| {
495 service_recommended_ram(registry_path, svc)
496 .ok()
497 .flatten()
498 .unwrap_or(128) })
500 .sum();
501
502 let browser_overhead = if test.needs_browser() { 512 } else { 0 };
504 let total = service_ram + 512 + browser_overhead; let rounded = total.div_ceil(512) * 512; rounded.max(1024) as u32
507}
508
509pub fn service_min_disk(registry_path: &Path, service_name: &str) -> Result<Option<u32>> {
511 let service_toml = registry_path.join(service_name).join("service.toml");
512 if !service_toml.exists() {
513 return Ok(None);
514 }
515 let content = std::fs::read_to_string(&service_toml)
516 .with_context(|| format!("failed to read {}", service_toml.display()))?;
517 let parsed: ServiceTomlDisk = toml::from_str(&content)
518 .with_context(|| format!("failed to parse {}", service_toml.display()))?;
519 Ok(parsed.requirements.and_then(|r| r.disk).map(|d| d.min))
520}
521
522pub fn vm_disk_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
525 let services: Vec<&str> = match test {
526 DiscoveredTest::Lifecycle { steps, .. } => steps
527 .iter()
528 .filter_map(|s| match s {
529 StepDef::Add { service, .. } => Some(service.as_str()),
530 StepDef::Shell { run, .. } => run
531 .split_whitespace()
532 .collect::<Vec<_>>()
533 .windows(3)
534 .find(|w| w[0] == "ryra" && w[1] == "add")
535 .map(|w| w[2]),
536 _ => None,
537 })
538 .collect(),
539 _ => test.services(),
540 };
541
542 let max_disk: u32 = services
543 .iter()
544 .filter_map(|svc| service_min_disk(registry_path, svc).ok().flatten())
545 .max()
546 .unwrap_or(0);
547
548 max_disk.max(20)
549}
550
551pub fn service_image(registry_path: &Path, service_name: &str) -> Result<Option<String>> {
553 let images = service_images(registry_path, service_name);
554 Ok(images.into_iter().next())
555}
556
557fn implied_services_from_args(args: &str) -> Vec<&str> {
566 let tokens: Vec<&str> = args.split_whitespace().collect();
567 let mut out: Vec<&str> = Vec::new();
568
569 let push = |svc: &'static str, out: &mut Vec<&str>| {
570 if !out.contains(&svc) {
571 out.push(svc);
572 }
573 };
574
575 let mut i = 0;
576 while i < tokens.len() {
577 let t = tokens[i];
578 if let Some(val) = t.strip_prefix("--smtp=") {
579 if !val.is_empty() && !out.contains(&val) {
580 out.push(val);
581 }
582 } else if t == "--smtp" {
583 if let Some(val) = tokens.get(i + 1)
584 && !val.starts_with("--")
585 && !out.contains(val)
586 {
587 out.push(val);
588 i += 1;
589 }
590 } else if t == "--auth" || t.starts_with("--auth=") {
591 push("authelia", &mut out);
592 } else if t == "--domain"
593 || t.starts_with("--domain=")
594 || t == "--url"
595 || t.starts_with("--url=")
596 {
597 push("caddy", &mut out);
598 }
599 i += 1;
600 }
601 out
602}
603
604pub fn service_images(registry_path: &Path, service_name: &str) -> Vec<String> {
606 let quadlets_dir = registry_path.join(service_name).join("quadlets");
607 let mut images = Vec::new();
608 if let Ok(entries) = std::fs::read_dir(&quadlets_dir) {
609 for entry in entries.flatten() {
610 let path = entry.path();
611 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
612 if !name.ends_with(".container") {
613 continue;
614 }
615 if let Ok(content) = std::fs::read_to_string(&path) {
616 for line in content.lines() {
617 let trimmed = line.trim();
618 if let Some(image) = trimmed.strip_prefix("Image=") {
619 let image = image.trim();
620 if !image.is_empty() && !images.contains(&image.to_string()) {
621 images.push(image.to_string());
622 }
623 }
624 }
625 }
626 }
627 }
628 images
629}
630
631pub fn images_for_test(registry_path: &Path, test: &DiscoveredTest) -> Vec<String> {
633 let mut images = Vec::new();
634 let add_image = |img: String, out: &mut Vec<String>| {
635 if !out.contains(&img) {
636 out.push(img);
637 }
638 };
639 let include_service = |svc: &str, out: &mut Vec<String>| {
640 for image in service_images(registry_path, svc) {
641 add_image(image, out);
642 }
643 };
644
645 match test {
646 DiscoveredTest::Lifecycle { steps, .. } => {
647 for step in steps {
648 match step {
649 StepDef::Add { service, args, .. } => {
650 include_service(service, &mut images);
651 if let Some(args_str) = args {
658 for implied in implied_services_from_args(args_str) {
659 include_service(implied, &mut images);
660 }
661 }
662 }
663 StepDef::Shell { run, .. } => {
664 let tokens: Vec<&str> = run.split_whitespace().collect();
666 if let Some(idx) = tokens
667 .windows(2)
668 .position(|w| w[0] == "ryra" && w[1] == "add")
669 {
670 if let Some(svc) = tokens.get(idx + 2) {
671 include_service(svc, &mut images);
672 }
673 let rest = &tokens[idx + 2..];
676 for implied in implied_services_from_args(&rest.join(" ")) {
677 include_service(implied, &mut images);
678 }
679 }
680 }
681 _ => {}
682 }
683 }
684 }
685 DiscoveredTest::Simple { setup, .. } => {
686 for service in &setup.services {
688 for image in service_images(registry_path, service) {
689 if !images.contains(&image) {
690 images.push(image);
691 }
692 }
693 }
694 if let Some(ref dir) = setup.quadlet_dir {
696 for quadlet in &setup.quadlets {
697 let full_path = dir.join(quadlet);
698 if quadlet.ends_with(".container")
699 && let Ok(content) = std::fs::read_to_string(&full_path)
700 {
701 for line in content.lines() {
702 let trimmed = line.trim();
703 if let Some(image) = trimmed.strip_prefix("Image=") {
704 let image = image.trim();
705 if !image.is_empty() && !images.contains(&image.to_string()) {
706 images.push(image.to_string());
707 }
708 }
709 }
710 }
711 }
712 }
713 }
714 }
715
716 images
717}
718
719#[derive(serde::Deserialize)]
724struct ServiceTomlRam {
725 #[serde(default)]
726 requirements: Option<RequirementsRam>,
727}
728
729#[derive(serde::Deserialize)]
730struct ServiceTomlDisk {
731 #[serde(default)]
732 requirements: Option<RequirementsDisk>,
733}
734
735#[derive(serde::Deserialize)]
736struct RequirementsDisk {
737 #[serde(default)]
738 disk: Option<DiskFields>,
739}
740
741#[derive(serde::Deserialize)]
742struct DiskFields {
743 min: u32,
744}
745
746#[derive(serde::Deserialize)]
747struct RequirementsRam {
748 ram: RamFields,
749}
750
751#[derive(serde::Deserialize)]
752struct RamFields {
753 #[serde(default)]
754 recommended: Option<u64>,
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 #[test]
762 fn discover_simple_test_from_test_toml() {
763 let dir = tempfile::tempdir().unwrap();
764 let test_toml = dir.path().join("test.toml");
765 std::fs::write(
766 &test_toml,
767 r#"
768[[tests]]
769name = "responds"
770run = "curl -sf http://127.0.0.1:$SERVICE_PORT_HTTP"
771
772[[tests]]
773name = "hostname"
774run = "curl -s http://127.0.0.1:$SERVICE_PORT_HTTP | grep -q Hostname"
775timeout = 10
776"#,
777 )
778 .unwrap();
779
780 let parsed = TestToml::parse(&test_toml).unwrap();
781 let mut out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
782 assert_eq!(
783 out.len(),
784 1,
785 "legacy shell form produces a single Simple test"
786 );
787 let test = out.remove(0);
788
789 assert_eq!(test.name(), "whoami");
790 assert!(!test.is_lifecycle());
791 assert_eq!(test.test_count(), 2);
792 assert_eq!(test.services(), vec!["whoami"]); assert_eq!(test.tests()[0].name, "responds");
794 assert_eq!(test.tests()[1].timeout_secs, 10);
795 }
796
797 #[test]
798 fn discover_simple_test_with_setup() {
799 let dir = tempfile::tempdir().unwrap();
800 let test_toml = dir.path().join("test.toml");
801 std::fs::write(
802 &test_toml,
803 r#"
804[test]
805name = "whoami-plus-postgres"
806
807[setup]
808services = ["whoami", "postgres"]
809
810[[tests]]
811name = "both-running"
812run = "echo ok"
813"#,
814 )
815 .unwrap();
816
817 let parsed = TestToml::parse(&test_toml).unwrap();
818 let mut out = discover_from_test_toml(&test_toml, &parsed, "combo", None).unwrap();
819 assert_eq!(out.len(), 1);
820 let test = out.remove(0);
821
822 assert_eq!(test.name(), "whoami-plus-postgres");
823 assert_eq!(test.services(), vec!["whoami", "postgres"]);
824 assert_eq!(test.test_count(), 1);
825 }
826
827 #[test]
828 fn discover_lifecycle_test() {
829 let dir = tempfile::tempdir().unwrap();
830 let test_toml = dir.path().join("test.toml");
831 std::fs::write(
832 &test_toml,
833 r#"
834[test]
835name = "remove-test"
836
837[[steps]]
838action = "add"
839service = "whoami"
840
841[[steps]]
842action = "wait"
843service = "whoami"
844
845[[steps]]
846action = "shell"
847name = "responds"
848run = "curl -sf http://localhost"
849
850[[steps]]
851action = "remove"
852service = "whoami"
853
854[[steps]]
855action = "shell"
856name = "gone"
857run = "! id whoami"
858"#,
859 )
860 .unwrap();
861
862 let parsed = TestToml::parse(&test_toml).unwrap();
863 let mut out = discover_from_test_toml(&test_toml, &parsed, "remove-test", None).unwrap();
864 assert_eq!(out.len(), 1);
865 let test = out.remove(0);
866
867 assert_eq!(test.name(), "remove-test");
868 assert!(test.is_lifecycle());
869 assert_eq!(test.test_count(), 5);
870 assert_eq!(test.services(), vec!["whoami"]); }
872
873 #[test]
874 fn discover_lifecycle_with_args() {
875 let dir = tempfile::tempdir().unwrap();
876 let test_toml = dir.path().join("test.toml");
877 std::fs::write(
878 &test_toml,
879 r#"
880[test]
881name = "auth-test"
882
883[[steps]]
884action = "add"
885service = "caddy"
886args = "--domain proxy.test.local"
887
888[[steps]]
889action = "shell"
890name = "caddy up"
891run = "curl -sf http://proxy.test.local"
892"#,
893 )
894 .unwrap();
895
896 let parsed = TestToml::parse(&test_toml).unwrap();
897 let mut out = discover_from_test_toml(&test_toml, &parsed, "auth-test", None).unwrap();
898 assert_eq!(out.len(), 1);
899 let test = out.remove(0);
900
901 assert!(test.is_lifecycle());
902 if let DiscoveredTest::Lifecycle { steps, .. } = &test {
903 if let StepDef::Add { service, args, .. } = &steps[0] {
904 assert_eq!(service, "caddy");
905 assert_eq!(args.as_deref(), Some("--domain proxy.test.local"));
906 } else {
907 panic!("expected Add step");
908 }
909 } else {
910 panic!("expected Lifecycle variant");
911 }
912 }
913
914 #[test]
915 fn discover_multi_test_service_owned() {
916 let dir = tempfile::tempdir().unwrap();
919 let svc_dir = dir.path().join("whoami");
920 std::fs::create_dir(&svc_dir).unwrap();
921 let test_toml = svc_dir.join("test.toml");
922 std::fs::write(
923 &test_toml,
924 r#"
925[[tests]]
926name = "whoami"
927[[tests.steps]]
928action = "add"
929service = "whoami"
930[[tests.steps]]
931action = "wait"
932service = "whoami"
933
934[[tests]]
935name = "diff"
936[[tests.steps]]
937action = "add"
938service = "whoami"
939[[tests.steps]]
940action = "shell"
941name = "idempotent"
942run = "true"
943
944[[tests]]
945name = "remove"
946[[tests.steps]]
947action = "add"
948service = "whoami"
949[[tests.steps]]
950action = "remove"
951service = "whoami"
952"#,
953 )
954 .unwrap();
955
956 let parsed = TestToml::parse(&test_toml).unwrap();
957 let out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
958 assert_eq!(out.len(), 3);
959
960 let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
963 assert_eq!(names, vec!["whoami", "whoami-diff", "whoami-remove"]);
964 for t in &out {
965 assert!(t.is_lifecycle());
966 }
967 }
968
969 #[test]
970 fn discover_multi_test_cross_cutting() {
971 let dir = tempfile::tempdir().unwrap();
973 let tests_dir = dir.path().join("tests");
974 std::fs::create_dir(&tests_dir).unwrap();
975 let test_toml = tests_dir.join("cross-thing.toml");
976 std::fs::write(
977 &test_toml,
978 r#"
979[[tests]]
980name = "first"
981[[tests.steps]]
982action = "add"
983service = "whoami"
984
985[[tests]]
986name = "second"
987[[tests.steps]]
988action = "add"
989service = "whoami"
990"#,
991 )
992 .unwrap();
993
994 let parsed = TestToml::parse(&test_toml).unwrap();
995 let out = discover_from_test_toml(&test_toml, &parsed, "cross-thing", None).unwrap();
996 assert_eq!(out.len(), 2);
997 let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
998 assert_eq!(names, vec!["first", "second"]);
999 }
1000
1001 #[test]
1002 fn reject_test_with_both_run_and_steps() {
1003 let dir = tempfile::tempdir().unwrap();
1004 let test_toml = dir.path().join("test.toml");
1005 std::fs::write(
1006 &test_toml,
1007 r#"
1008[[tests]]
1009name = "bad"
1010run = "true"
1011[[tests.steps]]
1012action = "add"
1013service = "whoami"
1014"#,
1015 )
1016 .unwrap();
1017
1018 let err = TestToml::parse(&test_toml).expect_err("must reject run+steps");
1019 let msg = format!("{err:#}");
1020 assert!(
1021 msg.contains("exactly one of `run` or `steps`"),
1022 "got: {msg}"
1023 );
1024 }
1025
1026 #[test]
1027 fn discover_registry() {
1028 let registry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ryra-core/registry");
1029 if !registry.exists() {
1030 return; }
1032 let discovered = discover(®istry).unwrap();
1033
1034 if discovered.is_empty() {
1039 return; }
1041
1042 let names: Vec<&str> = discovered.iter().map(|d| d.name()).collect();
1043
1044 for test in &discovered {
1046 assert!(!test.name().is_empty());
1047 assert!(test.test_count() > 0);
1048 }
1049
1050 if names.contains(&"whoami") {
1052 let whoami = discovered.iter().find(|d| d.name() == "whoami").unwrap();
1053 assert!(whoami.is_lifecycle());
1054 assert!(whoami.test_count() >= 1);
1055 }
1056 }
1057
1058 #[test]
1059 fn discover_local_project_from_dir() {
1060 let dir = tempfile::tempdir().unwrap();
1062 let project_dir = dir.path();
1063
1064 std::fs::write(
1066 project_dir.join("test.toml"),
1067 r#"
1068[test]
1069name = "test-app"
1070
1071[[tests]]
1072name = "responds"
1073run = "curl -sf http://127.0.0.1:8080"
1074"#,
1075 )
1076 .unwrap();
1077
1078 std::fs::write(
1080 project_dir.join("test-app.container"),
1081 "[Container]\nImage=docker.io/traefik/whoami:v1.11.0\n\n[Service]\nRestart=always\n",
1082 )
1083 .unwrap();
1084
1085 std::fs::write(project_dir.join("test-app.volume"), "[Volume]\n").unwrap();
1087
1088 let result = discover_local_project(project_dir).unwrap();
1089 assert!(result.is_some());
1090
1091 let test = result.unwrap();
1092 assert_eq!(test.name(), "test-app");
1093 assert!(test.has_quadlets());
1094 assert_eq!(test.test_count(), 1);
1095
1096 if let DiscoveredTest::Simple { setup, .. } = &test {
1097 assert!(setup.quadlet_dir.is_some());
1098 } else {
1101 panic!("expected Simple variant");
1102 }
1103 }
1104
1105 #[test]
1106 fn discover_local_project_with_setup_services() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let project_dir = dir.path();
1109
1110 std::fs::write(
1111 project_dir.join("test.toml"),
1112 r#"
1113[test]
1114name = "my-app"
1115
1116[setup]
1117services = ["postgres", "redis"]
1118quadlets = ["my-app.container"]
1119
1120[[tests]]
1121name = "health-check"
1122run = "curl -sf http://127.0.0.1:8080/health"
1123timeout = 10
1124"#,
1125 )
1126 .unwrap();
1127
1128 std::fs::write(
1130 project_dir.join("my-app.container"),
1131 "[Container]\nImage=docker.io/myapp:latest\n",
1132 )
1133 .unwrap();
1134
1135 let result = discover_local_project(project_dir).unwrap();
1136 let test = result.unwrap();
1137 assert_eq!(test.name(), "my-app");
1138 assert_eq!(test.services(), vec!["postgres", "redis"]);
1139 assert!(test.has_quadlets());
1140 }
1141
1142 #[test]
1143 fn discover_local_project_no_quadlets() {
1144 let dir = tempfile::tempdir().unwrap();
1145 std::fs::write(
1146 dir.path().join("test.toml"),
1147 "[[tests]]\nname = \"check\"\nrun = \"true\"\n",
1148 )
1149 .unwrap();
1150
1151 let result = discover_local_project(dir.path());
1152 assert!(result.is_err()); }
1154
1155 #[test]
1156 fn browser_flag_on_simple_test() {
1157 let dir = tempfile::tempdir().unwrap();
1158 let test_toml = dir.path().join("test.toml");
1159 std::fs::write(
1160 &test_toml,
1161 r#"
1162[test]
1163browser = true
1164
1165[[tests]]
1166name = "browser check"
1167run = "true"
1168"#,
1169 )
1170 .unwrap();
1171
1172 let parsed = TestToml::parse(&test_toml).unwrap();
1173 let mut out = discover_from_test_toml(&test_toml, &parsed, "my-test", None).unwrap();
1174 assert_eq!(out.len(), 1);
1175 let test = out.remove(0);
1176 assert!(test.needs_browser());
1177 }
1178}