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 anyhow::bail!(
218 "test.toml found at {} but no quadlet files (.container, .volume, .network, .pod) in the same directory",
219 test_toml_path.display()
220 );
221 }
222
223 let project_dir = std::fs::canonicalize(project_dir)
224 .with_context(|| format!("failed to canonicalize {}", project_dir.display()))?;
225
226 let dir_name = project_dir
228 .file_name()
229 .and_then(|n| n.to_str())
230 .unwrap_or("project")
231 .to_string();
232
233 let mut tests =
234 discover_from_test_toml(&test_toml_path, &parsed, &dir_name, Some(&project_dir))?;
235
236 if tests.len() != 1 {
240 anyhow::bail!(
241 "local project test.toml must describe exactly one test (got {}); \
242 multi-test [[tests]] arrays are only supported inside the registry",
243 tests.len()
244 );
245 }
246 let mut test = tests.remove(0);
247
248 if let DiscoveredTest::Simple { ref mut setup, .. } = test
250 && setup.quadlets.is_empty()
251 && !quadlet_files.is_empty()
252 {
253 setup.quadlets = quadlet_files;
254 }
255
256 Ok(Some(test))
257}
258
259pub fn discover(registry_path: &Path) -> Result<Vec<DiscoveredTest>> {
264 let mut discovered = Vec::new();
265
266 let entries = std::fs::read_dir(registry_path)
268 .with_context(|| format!("failed to read registry at {}", registry_path.display()))?;
269
270 for entry in entries {
271 let entry = entry?;
272 let path = entry.path();
273
274 if !path.is_dir() {
276 continue;
277 }
278 let dir_name = match path.file_name().and_then(|n| n.to_str()) {
279 Some(n) => n.to_string(),
280 None => continue,
281 };
282 if dir_name == "tests" || dir_name.starts_with('.') {
283 continue;
284 }
285
286 let test_toml_path = path.join("test.toml");
287 if !test_toml_path.exists() {
288 continue;
289 }
290
291 match TestToml::parse(&test_toml_path) {
292 Ok(parsed) => {
293 match discover_from_test_toml(&test_toml_path, &parsed, &dir_name, None) {
294 Ok(tests) => discovered.extend(tests),
295 Err(e) => {
296 eprintln!(
297 "warning: failed to process {}: {e}",
298 test_toml_path.display()
299 );
300 }
301 }
302 }
303 Err(e) => {
304 eprintln!("warning: failed to parse {}: {e}", test_toml_path.display());
305 }
306 }
307 }
308
309 let tests_dir = registry_path.join("tests");
311 if tests_dir.is_dir() {
312 let entries = std::fs::read_dir(&tests_dir)
313 .with_context(|| format!("failed to read {}", tests_dir.display()))?;
314
315 for entry in entries {
316 let entry = entry?;
317 let path = entry.path();
318
319 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
320 continue;
321 }
322
323 let file_stem = path
324 .file_stem()
325 .and_then(|s| s.to_str())
326 .unwrap_or("unknown")
327 .to_string();
328
329 match TestToml::parse(&path) {
330 Ok(parsed) => match discover_from_test_toml(&path, &parsed, &file_stem, None) {
331 Ok(tests) => discovered.extend(tests),
332 Err(e) => {
333 eprintln!("warning: failed to process {}: {e}", path.display());
334 }
335 },
336 Err(e) => {
337 eprintln!("warning: failed to parse {}: {e}", path.display());
338 }
339 }
340 }
341 }
342
343 discovered.sort_by(|a, b| a.name().cmp(b.name()));
345
346 Ok(discovered)
347}
348
349fn discover_from_test_toml(
367 path: &Path,
368 parsed: &TestToml,
369 service_name_hint: &str,
370 quadlet_dir: Option<&Path>,
371) -> Result<Vec<DiscoveredTest>> {
372 let new_format_tests: Vec<&crate::test_toml::TestDef> = parsed
374 .tests
375 .iter()
376 .filter(|t| !t.steps.is_empty())
377 .collect();
378 if !new_format_tests.is_empty() {
379 let is_service_owned = path
385 .parent()
386 .and_then(|p| p.file_name())
387 .and_then(|n| n.to_str())
388 == Some(service_name_hint);
389
390 let mut out = Vec::with_capacity(new_format_tests.len());
391 for t in new_format_tests {
392 let qualified = if is_service_owned && t.name != service_name_hint {
393 format!("{service_name_hint}-{}", t.name)
394 } else {
395 t.name.clone()
396 };
397 out.push(DiscoveredTest::Lifecycle {
398 name: qualified,
399 source: path.to_path_buf(),
400 steps: t.steps.clone(),
401 browser: t.browser || parsed.needs_browser(),
402 ram_override: t.ram.or(parsed.ram_override()),
403 requires_sudo: t.requires_sudo || parsed.requires_sudo(),
404 });
405 }
406 return Ok(out);
407 }
408
409 let name = parsed.name_or_default(path);
411 let test_name = if parsed.test.as_ref().and_then(|t| t.name.as_ref()).is_some() {
412 name
413 } else {
414 service_name_hint.to_string()
415 };
416 let browser = parsed.needs_browser();
417 let ram_override = parsed.ram_override();
418 let requires_sudo = parsed.requires_sudo();
419
420 if parsed.is_lifecycle() {
421 return Ok(vec![DiscoveredTest::Lifecycle {
422 name: test_name,
423 source: path.to_path_buf(),
424 steps: parsed.steps.clone(),
425 browser,
426 ram_override,
427 requires_sudo,
428 }]);
429 }
430
431 let setup = match &parsed.setup {
433 Some(s) => SetupConfig {
434 services: s.services.clone(),
435 quadlets: s.quadlets.clone(),
436 quadlet_dir: quadlet_dir.map(PathBuf::from),
437 },
438 None => SetupConfig {
439 services: vec![service_name_hint.to_string()],
440 quadlets: Vec::new(),
441 quadlet_dir: quadlet_dir.map(PathBuf::from),
442 },
443 };
444
445 let tests = parsed
446 .tests
447 .iter()
448 .map(|t| TestEntry {
449 name: t.name.clone(),
450 run: t.run.clone().unwrap_or_default(),
451 timeout_secs: t.timeout,
452 env: t.env.clone(),
453 })
454 .collect();
455
456 Ok(vec![DiscoveredTest::Simple {
457 name: test_name,
458 source: path.to_path_buf(),
459 setup,
460 tests,
461 browser,
462 ram_override,
463 requires_sudo,
464 }])
465}
466
467pub fn service_recommended_ram(registry_path: &Path, service_name: &str) -> Result<Option<u64>> {
469 let service_toml = registry_path.join(service_name).join("service.toml");
470 if !service_toml.exists() {
471 return Ok(None);
472 }
473 let content = std::fs::read_to_string(&service_toml)
474 .with_context(|| format!("failed to read {}", service_toml.display()))?;
475 let parsed: ServiceTomlRam = toml::from_str(&content)
476 .with_context(|| format!("failed to parse {}", service_toml.display()))?;
477 Ok(parsed.requirements.and_then(|r| r.ram.recommended))
478}
479
480pub fn vm_memory_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
484 if let Some(ram) = test.ram_override() {
485 return ram;
486 }
487
488 let services: Vec<&str> = match test {
489 DiscoveredTest::Lifecycle { steps, .. } => steps
490 .iter()
491 .filter_map(|s| match s {
492 StepDef::Add { service, .. } => Some(service.as_str()),
493 StepDef::Shell { run, .. } => run
495 .split_whitespace()
496 .collect::<Vec<_>>()
497 .windows(3)
498 .find(|w| w[0] == "ryra" && w[1] == "add")
499 .map(|w| w[2]),
500 _ => None,
501 })
502 .collect(),
503 _ => test.services(),
504 };
505
506 let service_ram: u64 = services
507 .iter()
508 .map(|svc| {
509 service_recommended_ram(registry_path, svc)
510 .ok()
511 .flatten()
512 .unwrap_or(128) })
514 .sum();
515
516 let browser_overhead = if test.needs_browser() { 512 } else { 0 };
518 let total = service_ram + 512 + browser_overhead; let rounded = total.div_ceil(512) * 512; rounded.max(1024) as u32
521}
522
523pub fn service_min_disk(registry_path: &Path, service_name: &str) -> Result<Option<u32>> {
525 let service_toml = registry_path.join(service_name).join("service.toml");
526 if !service_toml.exists() {
527 return Ok(None);
528 }
529 let content = std::fs::read_to_string(&service_toml)
530 .with_context(|| format!("failed to read {}", service_toml.display()))?;
531 let parsed: ServiceTomlDisk = toml::from_str(&content)
532 .with_context(|| format!("failed to parse {}", service_toml.display()))?;
533 Ok(parsed.requirements.and_then(|r| r.disk).map(|d| d.min))
534}
535
536pub fn vm_disk_for_test(registry_path: &Path, test: &DiscoveredTest) -> u32 {
539 let services: Vec<&str> = match test {
540 DiscoveredTest::Lifecycle { steps, .. } => steps
541 .iter()
542 .filter_map(|s| match s {
543 StepDef::Add { service, .. } => Some(service.as_str()),
544 StepDef::Shell { run, .. } => run
545 .split_whitespace()
546 .collect::<Vec<_>>()
547 .windows(3)
548 .find(|w| w[0] == "ryra" && w[1] == "add")
549 .map(|w| w[2]),
550 _ => None,
551 })
552 .collect(),
553 _ => test.services(),
554 };
555
556 let max_disk: u32 = services
557 .iter()
558 .filter_map(|svc| service_min_disk(registry_path, svc).ok().flatten())
559 .max()
560 .unwrap_or(0);
561
562 max_disk.max(20)
563}
564
565pub fn service_image(registry_path: &Path, service_name: &str) -> Result<Option<String>> {
567 let images = service_images(registry_path, service_name);
568 Ok(images.into_iter().next())
569}
570
571fn implied_services_from_args(args: &str) -> Vec<&str> {
580 let tokens: Vec<&str> = args.split_whitespace().collect();
581 let mut out: Vec<&str> = Vec::new();
582
583 let push = |svc: &'static str, out: &mut Vec<&str>| {
584 if !out.contains(&svc) {
585 out.push(svc);
586 }
587 };
588
589 let mut i = 0;
590 while i < tokens.len() {
591 let t = tokens[i];
592 if let Some(val) = t.strip_prefix("--smtp=") {
593 if !val.is_empty() && !out.contains(&val) {
594 out.push(val);
595 }
596 } else if t == "--smtp" {
597 if let Some(val) = tokens.get(i + 1)
598 && !val.starts_with("--")
599 && !out.contains(val)
600 {
601 out.push(val);
602 i += 1;
603 }
604 } else if t == "--auth" || t.starts_with("--auth=") {
605 push("authelia", &mut out);
606 } else if t == "--domain"
607 || t.starts_with("--domain=")
608 || t == "--url"
609 || t.starts_with("--url=")
610 {
611 push("caddy", &mut out);
612 }
613 i += 1;
614 }
615 out
616}
617
618pub fn service_images(registry_path: &Path, service_name: &str) -> Vec<String> {
620 let quadlets_dir = registry_path.join(service_name).join("quadlets");
621 let mut images = Vec::new();
622 if let Ok(entries) = std::fs::read_dir(&quadlets_dir) {
623 for entry in entries.flatten() {
624 let path = entry.path();
625 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
626 if !name.ends_with(".container") {
627 continue;
628 }
629 if let Ok(content) = std::fs::read_to_string(&path) {
630 for line in content.lines() {
631 let trimmed = line.trim();
632 if let Some(image) = trimmed.strip_prefix("Image=") {
633 let image = image.trim();
634 if !image.is_empty() && !images.contains(&image.to_string()) {
635 images.push(image.to_string());
636 }
637 }
638 }
639 }
640 }
641 }
642 images
643}
644
645pub fn images_for_test(registry_path: &Path, test: &DiscoveredTest) -> Vec<String> {
647 let mut images = Vec::new();
648 let add_image = |img: String, out: &mut Vec<String>| {
649 if !out.contains(&img) {
650 out.push(img);
651 }
652 };
653 let include_service = |svc: &str, out: &mut Vec<String>| {
654 for image in service_images(registry_path, svc) {
655 add_image(image, out);
656 }
657 };
658
659 match test {
660 DiscoveredTest::Lifecycle { steps, .. } => {
661 for step in steps {
662 match step {
663 StepDef::Add { service, args, .. } => {
664 include_service(service, &mut images);
665 if let Some(args_str) = args {
672 for implied in implied_services_from_args(args_str) {
673 include_service(implied, &mut images);
674 }
675 }
676 }
677 StepDef::Shell { run, .. } => {
678 let tokens: Vec<&str> = run.split_whitespace().collect();
680 if let Some(idx) = tokens
681 .windows(2)
682 .position(|w| w[0] == "ryra" && w[1] == "add")
683 {
684 if let Some(svc) = tokens.get(idx + 2) {
685 include_service(svc, &mut images);
686 }
687 let rest = &tokens[idx + 2..];
690 for implied in implied_services_from_args(&rest.join(" ")) {
691 include_service(implied, &mut images);
692 }
693 }
694 }
695 _ => {}
696 }
697 }
698 }
699 DiscoveredTest::Simple { setup, .. } => {
700 for service in &setup.services {
702 for image in service_images(registry_path, service) {
703 if !images.contains(&image) {
704 images.push(image);
705 }
706 }
707 }
708 if let Some(ref dir) = setup.quadlet_dir {
710 for quadlet in &setup.quadlets {
711 let full_path = dir.join(quadlet);
712 if quadlet.ends_with(".container")
713 && let Ok(content) = std::fs::read_to_string(&full_path)
714 {
715 for line in content.lines() {
716 let trimmed = line.trim();
717 if let Some(image) = trimmed.strip_prefix("Image=") {
718 let image = image.trim();
719 if !image.is_empty() && !images.contains(&image.to_string()) {
720 images.push(image.to_string());
721 }
722 }
723 }
724 }
725 }
726 }
727 }
728 }
729
730 images
731}
732
733#[derive(serde::Deserialize)]
738struct ServiceTomlRam {
739 #[serde(default)]
740 requirements: Option<RequirementsRam>,
741}
742
743#[derive(serde::Deserialize)]
744struct ServiceTomlDisk {
745 #[serde(default)]
746 requirements: Option<RequirementsDisk>,
747}
748
749#[derive(serde::Deserialize)]
750struct RequirementsDisk {
751 #[serde(default)]
752 disk: Option<DiskFields>,
753}
754
755#[derive(serde::Deserialize)]
756struct DiskFields {
757 min: u32,
758}
759
760#[derive(serde::Deserialize)]
761struct RequirementsRam {
762 ram: RamFields,
763}
764
765#[derive(serde::Deserialize)]
766struct RamFields {
767 #[serde(default)]
768 recommended: Option<u64>,
769}
770
771#[cfg(test)]
772mod tests {
773 use super::*;
774
775 #[test]
776 fn discover_simple_test_from_test_toml() {
777 let dir = tempfile::tempdir().unwrap();
778 let test_toml = dir.path().join("test.toml");
779 std::fs::write(
780 &test_toml,
781 r#"
782[[tests]]
783name = "responds"
784run = "curl -sf http://127.0.0.1:$SERVICE_PORT_HTTP"
785
786[[tests]]
787name = "hostname"
788run = "curl -s http://127.0.0.1:$SERVICE_PORT_HTTP | grep -q Hostname"
789timeout = 10
790"#,
791 )
792 .unwrap();
793
794 let parsed = TestToml::parse(&test_toml).unwrap();
795 let mut out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
796 assert_eq!(
797 out.len(),
798 1,
799 "legacy shell form produces a single Simple test"
800 );
801 let test = out.remove(0);
802
803 assert_eq!(test.name(), "whoami");
804 assert!(!test.is_lifecycle());
805 assert_eq!(test.test_count(), 2);
806 assert_eq!(test.services(), vec!["whoami"]); assert_eq!(test.tests()[0].name, "responds");
808 assert_eq!(test.tests()[1].timeout_secs, 10);
809 }
810
811 #[test]
812 fn discover_simple_test_with_setup() {
813 let dir = tempfile::tempdir().unwrap();
814 let test_toml = dir.path().join("test.toml");
815 std::fs::write(
816 &test_toml,
817 r#"
818[test]
819name = "whoami-plus-postgres"
820
821[setup]
822services = ["whoami", "postgres"]
823
824[[tests]]
825name = "both-running"
826run = "echo ok"
827"#,
828 )
829 .unwrap();
830
831 let parsed = TestToml::parse(&test_toml).unwrap();
832 let mut out = discover_from_test_toml(&test_toml, &parsed, "combo", None).unwrap();
833 assert_eq!(out.len(), 1);
834 let test = out.remove(0);
835
836 assert_eq!(test.name(), "whoami-plus-postgres");
837 assert_eq!(test.services(), vec!["whoami", "postgres"]);
838 assert_eq!(test.test_count(), 1);
839 }
840
841 #[test]
842 fn discover_lifecycle_test() {
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 = "remove-test"
850
851[[steps]]
852action = "add"
853service = "whoami"
854
855[[steps]]
856action = "wait"
857service = "whoami"
858
859[[steps]]
860action = "shell"
861name = "responds"
862run = "curl -sf http://localhost"
863
864[[steps]]
865action = "remove"
866service = "whoami"
867
868[[steps]]
869action = "shell"
870name = "gone"
871run = "! id whoami"
872"#,
873 )
874 .unwrap();
875
876 let parsed = TestToml::parse(&test_toml).unwrap();
877 let mut out = discover_from_test_toml(&test_toml, &parsed, "remove-test", None).unwrap();
878 assert_eq!(out.len(), 1);
879 let test = out.remove(0);
880
881 assert_eq!(test.name(), "remove-test");
882 assert!(test.is_lifecycle());
883 assert_eq!(test.test_count(), 5);
884 assert_eq!(test.services(), vec!["whoami"]); }
886
887 #[test]
888 fn discover_lifecycle_with_args() {
889 let dir = tempfile::tempdir().unwrap();
890 let test_toml = dir.path().join("test.toml");
891 std::fs::write(
892 &test_toml,
893 r#"
894[test]
895name = "auth-test"
896
897[[steps]]
898action = "add"
899service = "caddy"
900args = "--domain proxy.test.local"
901
902[[steps]]
903action = "shell"
904name = "caddy up"
905run = "curl -sf http://proxy.test.local"
906"#,
907 )
908 .unwrap();
909
910 let parsed = TestToml::parse(&test_toml).unwrap();
911 let mut out = discover_from_test_toml(&test_toml, &parsed, "auth-test", None).unwrap();
912 assert_eq!(out.len(), 1);
913 let test = out.remove(0);
914
915 assert!(test.is_lifecycle());
916 if let DiscoveredTest::Lifecycle { steps, .. } = &test {
917 if let StepDef::Add { service, args, .. } = &steps[0] {
918 assert_eq!(service, "caddy");
919 assert_eq!(args.as_deref(), Some("--domain proxy.test.local"));
920 } else {
921 panic!("expected Add step");
922 }
923 } else {
924 panic!("expected Lifecycle variant");
925 }
926 }
927
928 #[test]
929 fn discover_multi_test_service_owned() {
930 let dir = tempfile::tempdir().unwrap();
933 let svc_dir = dir.path().join("whoami");
934 std::fs::create_dir(&svc_dir).unwrap();
935 let test_toml = svc_dir.join("test.toml");
936 std::fs::write(
937 &test_toml,
938 r#"
939[[tests]]
940name = "whoami"
941[[tests.steps]]
942action = "add"
943service = "whoami"
944[[tests.steps]]
945action = "wait"
946service = "whoami"
947
948[[tests]]
949name = "diff"
950[[tests.steps]]
951action = "add"
952service = "whoami"
953[[tests.steps]]
954action = "shell"
955name = "idempotent"
956run = "true"
957
958[[tests]]
959name = "remove"
960[[tests.steps]]
961action = "add"
962service = "whoami"
963[[tests.steps]]
964action = "remove"
965service = "whoami"
966"#,
967 )
968 .unwrap();
969
970 let parsed = TestToml::parse(&test_toml).unwrap();
971 let out = discover_from_test_toml(&test_toml, &parsed, "whoami", None).unwrap();
972 assert_eq!(out.len(), 3);
973
974 let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
977 assert_eq!(names, vec!["whoami", "whoami-diff", "whoami-remove"]);
978 for t in &out {
979 assert!(t.is_lifecycle());
980 }
981 }
982
983 #[test]
984 fn discover_multi_test_cross_cutting() {
985 let dir = tempfile::tempdir().unwrap();
987 let tests_dir = dir.path().join("tests");
988 std::fs::create_dir(&tests_dir).unwrap();
989 let test_toml = tests_dir.join("cross-thing.toml");
990 std::fs::write(
991 &test_toml,
992 r#"
993[[tests]]
994name = "first"
995[[tests.steps]]
996action = "add"
997service = "whoami"
998
999[[tests]]
1000name = "second"
1001[[tests.steps]]
1002action = "add"
1003service = "whoami"
1004"#,
1005 )
1006 .unwrap();
1007
1008 let parsed = TestToml::parse(&test_toml).unwrap();
1009 let out = discover_from_test_toml(&test_toml, &parsed, "cross-thing", None).unwrap();
1010 assert_eq!(out.len(), 2);
1011 let names: Vec<&str> = out.iter().map(|t| t.name()).collect();
1012 assert_eq!(names, vec!["first", "second"]);
1013 }
1014
1015 #[test]
1016 fn reject_test_with_both_run_and_steps() {
1017 let dir = tempfile::tempdir().unwrap();
1018 let test_toml = dir.path().join("test.toml");
1019 std::fs::write(
1020 &test_toml,
1021 r#"
1022[[tests]]
1023name = "bad"
1024run = "true"
1025[[tests.steps]]
1026action = "add"
1027service = "whoami"
1028"#,
1029 )
1030 .unwrap();
1031
1032 let err = TestToml::parse(&test_toml).expect_err("must reject run+steps");
1033 let msg = format!("{err:#}");
1034 assert!(
1035 msg.contains("exactly one of `run` or `steps`"),
1036 "got: {msg}"
1037 );
1038 }
1039
1040 #[test]
1041 fn discover_registry() {
1042 let registry = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ryra-core/registry");
1043 if !registry.exists() {
1044 return; }
1046 let discovered = discover(®istry).unwrap();
1047
1048 if discovered.is_empty() {
1053 return; }
1055
1056 let names: Vec<&str> = discovered.iter().map(|d| d.name()).collect();
1057
1058 for test in &discovered {
1060 assert!(!test.name().is_empty());
1061 assert!(test.test_count() > 0);
1062 }
1063
1064 if names.contains(&"whoami") {
1066 let whoami = discovered.iter().find(|d| d.name() == "whoami").unwrap();
1067 assert!(whoami.is_lifecycle());
1068 assert!(whoami.test_count() >= 1);
1069 }
1070 }
1071
1072 #[test]
1073 fn discover_local_project_from_dir() {
1074 let dir = tempfile::tempdir().unwrap();
1076 let project_dir = dir.path();
1077
1078 std::fs::write(
1080 project_dir.join("test.toml"),
1081 r#"
1082[test]
1083name = "test-app"
1084
1085[[tests]]
1086name = "responds"
1087run = "curl -sf http://127.0.0.1:8080"
1088"#,
1089 )
1090 .unwrap();
1091
1092 std::fs::write(
1094 project_dir.join("test-app.container"),
1095 "[Container]\nImage=docker.io/traefik/whoami:v1.11.0\n\n[Service]\nRestart=always\n",
1096 )
1097 .unwrap();
1098
1099 std::fs::write(project_dir.join("test-app.volume"), "[Volume]\n").unwrap();
1101
1102 let result = discover_local_project(project_dir).unwrap();
1103 assert!(result.is_some());
1104
1105 let test = result.unwrap();
1106 assert_eq!(test.name(), "test-app");
1107 assert!(test.has_quadlets());
1108 assert_eq!(test.test_count(), 1);
1109
1110 if let DiscoveredTest::Simple { setup, .. } = &test {
1111 assert!(setup.quadlet_dir.is_some());
1112 } else {
1115 panic!("expected Simple variant");
1116 }
1117 }
1118
1119 #[test]
1120 fn discover_local_project_with_setup_services() {
1121 let dir = tempfile::tempdir().unwrap();
1122 let project_dir = dir.path();
1123
1124 std::fs::write(
1125 project_dir.join("test.toml"),
1126 r#"
1127[test]
1128name = "my-app"
1129
1130[setup]
1131services = ["postgres", "redis"]
1132quadlets = ["my-app.container"]
1133
1134[[tests]]
1135name = "health-check"
1136run = "curl -sf http://127.0.0.1:8080/health"
1137timeout = 10
1138"#,
1139 )
1140 .unwrap();
1141
1142 std::fs::write(
1144 project_dir.join("my-app.container"),
1145 "[Container]\nImage=docker.io/myapp:latest\n",
1146 )
1147 .unwrap();
1148
1149 let result = discover_local_project(project_dir).unwrap();
1150 let test = result.unwrap();
1151 assert_eq!(test.name(), "my-app");
1152 assert_eq!(test.services(), vec!["postgres", "redis"]);
1153 assert!(test.has_quadlets());
1154 }
1155
1156 #[test]
1157 fn discover_local_project_no_quadlets() {
1158 let dir = tempfile::tempdir().unwrap();
1159 std::fs::write(
1160 dir.path().join("test.toml"),
1161 "[[tests]]\nname = \"check\"\nrun = \"true\"\n",
1162 )
1163 .unwrap();
1164
1165 let result = discover_local_project(dir.path());
1166 assert!(result.is_err()); }
1168
1169 #[test]
1170 fn browser_flag_on_simple_test() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let test_toml = dir.path().join("test.toml");
1173 std::fs::write(
1174 &test_toml,
1175 r#"
1176[test]
1177browser = true
1178
1179[[tests]]
1180name = "browser check"
1181run = "true"
1182"#,
1183 )
1184 .unwrap();
1185
1186 let parsed = TestToml::parse(&test_toml).unwrap();
1187 let mut out = discover_from_test_toml(&test_toml, &parsed, "my-test", None).unwrap();
1188 assert_eq!(out.len(), 1);
1189 let test = out.remove(0);
1190 assert!(test.needs_browser());
1191 }
1192}