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