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