1pub mod executor;
2pub mod registry;
3mod reports;
4mod runner;
5mod scenario;
6pub mod test_toml;
7
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use clap::Parser;
12use tokio::sync::Semaphore;
13
14use ryra_vm::image::Distro;
15use ryra_vm::machine::{self, Machine, SpawnOpts};
16use ryra_vm::{image, ports};
17use scenario::ScenarioResult;
18
19fn install_signal_handler() {
21 unsafe {
24 libc::signal(
25 libc::SIGINT,
26 signal_handler as *const () as libc::sighandler_t,
27 );
28 }
29}
30
31extern "C" fn signal_handler(_sig: libc::c_int) {
32 let msg = b"\nInterrupted - shutting down VMs...\n";
34 unsafe {
35 libc::write(2, msg.as_ptr() as *const libc::c_void, msg.len());
36 }
37 machine::cleanup_all_vms();
38 std::process::exit(130); }
40
41fn render_list(discovered: &[registry::DiscoveredTest], registry_path: &Path, verbose: bool) {
54 if discovered.is_empty() {
55 println!("No tests discovered.");
56 return;
57 }
58
59 let tests_dir = registry_path.join("tests");
60 let is_cross_cutting = |p: &Path| p.starts_with(&tests_dir);
61
62 let mut service_groups: Vec<(String, Vec<®istry::DiscoveredTest>)> = Vec::new();
65 let mut cross_cutting: Vec<®istry::DiscoveredTest> = Vec::new();
66 for test in discovered {
67 let src = test.source();
68 if is_cross_cutting(src) {
69 cross_cutting.push(test);
70 continue;
71 }
72 let svc = src
73 .parent()
74 .and_then(|p| p.file_name())
75 .and_then(|n| n.to_str())
76 .unwrap_or("<unknown>")
77 .to_string();
78 if let Some((_, bucket)) = service_groups.iter_mut().find(|(s, _)| s == &svc) {
79 bucket.push(test);
80 } else {
81 service_groups.push((svc, vec![test]));
82 }
83 }
84 service_groups.sort_by(|a, b| a.0.cmp(&b.0));
85 cross_cutting.sort_by(|a, b| a.name().cmp(b.name()));
86
87 let total_tests: usize = discovered.len();
88 let file_count = service_groups.len() + cross_cutting.len();
89 println!("{total_tests} tests across {file_count} files");
90
91 let line = |t: ®istry::DiscoveredTest, indent: &str| {
92 let kinds = t.step_kinds().join(" → ");
93 let browser = if t.needs_browser() { " [browser]" } else { "" };
94 let step_count = t.test_count();
95 println!(
96 "{indent}{:<34} {} step{}{browser} · {kinds}",
97 t.name(),
98 step_count,
99 if step_count == 1 { "" } else { "s" },
100 );
101 if !verbose {
102 return;
103 }
104 let step_indent = format!("{indent} ");
107 if let registry::DiscoveredTest::Lifecycle { steps, .. } = t {
108 for (i, step) in steps.iter().enumerate() {
109 let described = step.describe();
110 if let Some((head, rest)) = described.split_first() {
111 println!("{step_indent}{:>2}. {head}", i + 1);
112 for l in rest {
113 println!("{step_indent} {l}");
114 }
115 }
116 }
117 } else if let registry::DiscoveredTest::Simple { tests, .. } = t {
118 for (i, entry) in tests.iter().enumerate() {
119 println!(
120 "{step_indent}{:>2}. shell '{}' (timeout={}s)",
121 i + 1,
122 entry.name,
123 entry.timeout_secs
124 );
125 for l in entry.run.trim().lines() {
126 println!("{step_indent} | {l}");
127 }
128 }
129 }
130 };
131
132 if !service_groups.is_empty() {
133 println!("─── Service tests (registry/<service>/test.toml) ───");
134 for (svc, tests) in &service_groups {
135 println!("{svc}");
136 for t in tests {
137 line(t, " ");
138 }
139 }
140 }
141
142 if !cross_cutting.is_empty() {
143 println!("─── Service-agnostic tests (registry/tests/*.toml) ───");
144 for t in &cross_cutting {
145 line(t, "");
146 }
147 }
148}
149
150#[derive(Parser, Debug)]
151#[command(
152 name = "ryra-e2e",
153 about = "E2E test runner for ryra — spins up QEMU VMs for integration testing"
154)]
155pub struct Args {
156 #[arg(long, default_value_t = 1)]
158 pub parallel: usize,
159
160 #[arg(long, default_value_t = Distro::Debian13)]
162 pub distro: Distro,
163
164 #[arg(long)]
166 pub redownload: bool,
167
168 #[arg(long)]
170 pub ryra_bin: Option<PathBuf>,
171
172 #[arg(long)]
174 pub keep_failed: bool,
175
176 #[arg(long)]
179 pub keep_alive: bool,
180
181 #[arg(long)]
183 pub no_kvm: bool,
184
185 #[arg(long)]
187 pub no_vm: bool,
188
189 #[arg(long)]
192 pub retest: bool,
193
194 #[arg(long)]
196 pub memory: Option<u32>,
197
198 #[arg(long, default_value_t = 2)]
200 pub cpus: u32,
201
202 #[arg(long, short)]
204 pub verbose: bool,
205
206 #[arg(long)]
208 pub registry: Option<PathBuf>,
209
210 #[arg(long)]
212 pub project: Option<PathBuf>,
213
214 #[arg(long)]
216 pub list: bool,
217
218 pub tests: Vec<String>,
220}
221
222fn find_ryra_binary() -> Result<PathBuf> {
223 let exe = std::env::current_exe()
229 .context("failed to resolve current executable path for ryra binary")?;
230 std::fs::canonicalize(&exe).context("failed to canonicalize current executable path")
231}
232
233fn newest_source_newer_than(binary: &Path) -> Result<Option<(PathBuf, std::time::SystemTime)>> {
237 let bin_mtime = std::fs::metadata(binary)
238 .with_context(|| format!("stat binary {}", binary.display()))?
239 .modified()
240 .context("binary modified-time")?;
241 let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
242 let crates_dir = match std::fs::canonicalize(workspace_root.join("crates")) {
243 Ok(p) => p,
244 Err(_) => return Ok(None),
246 };
247
248 fn is_source(path: &Path) -> bool {
249 if path.extension().and_then(|s| s.to_str()) == Some("rs") {
250 return true;
251 }
252 matches!(
253 path.file_name().and_then(|n| n.to_str()),
254 Some("Cargo.toml")
255 )
256 }
257
258 fn walk(
259 dir: &Path,
260 bin_mtime: std::time::SystemTime,
261 newest: &mut Option<(PathBuf, std::time::SystemTime)>,
262 ) -> Result<()> {
263 for entry in
264 std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))?
265 {
266 let entry = entry?;
267 let path = entry.path();
268 let ft = entry.file_type()?;
269 if ft.is_dir() {
270 if matches!(
272 path.file_name().and_then(|n| n.to_str()),
273 Some("target") | Some(".git") | Some("node_modules")
274 ) {
275 continue;
276 }
277 walk(&path, bin_mtime, newest)?;
278 } else if ft.is_file() && is_source(&path) {
279 let mtime = entry.metadata()?.modified()?;
280 if mtime > bin_mtime && newest.as_ref().is_none_or(|(_, t)| mtime > *t) {
281 *newest = Some((path, mtime));
282 }
283 }
284 }
285 Ok(())
286 }
287
288 let mut newest = None;
289 walk(&crates_dir, bin_mtime, &mut newest)?;
290 Ok(newest)
291}
292
293fn ensure_binary_fresh(binary: &Path) -> Result<()> {
298 let Some((src, _)) = newest_source_newer_than(binary)? else {
299 return Ok(());
300 };
301 anyhow::bail!(
302 "ryra binary is older than source {}.\n \
303 Binary: {}\n \
304 Rebuild: cargo build --release --bin ryra\n \
305 (or pass --ryra-bin <path> to skip this check)",
306 src.display(),
307 binary.display(),
308 )
309}
310
311fn print_summary(results: &[ScenarioResult], wall_clock: std::time::Duration) {
312 println!("\n========================================");
313 println!(" Results");
314 println!("========================================\n");
315
316 for result in results {
317 print!("{result}");
318 }
319
320 let passed = results.iter().filter(|r| r.passed()).count();
321 let failed = results.len() - passed;
322
323 println!("----------------------------------------");
324 println!(
325 "{passed} passed, {failed} failed, {} total ({:.0}s wall clock)",
326 results.len(),
327 wall_clock.as_secs_f64()
328 );
329 println!("========================================");
330}
331
332fn save_results(results: &[ScenarioResult]) -> Result<()> {
333 reports::save_run_results(results)?;
334 reports::print_results_paths(results);
335 Ok(())
336}
337
338const HOST_RESERVE_MB: u64 = 1024;
342
343fn plan_parallelism(requested: usize, sorted_mems_desc: &[u32]) -> usize {
347 let mem = match ryra_vm::read_host_memory() {
348 Some(m) => m,
349 None => {
350 let total_mb: u64 = sorted_mems_desc
351 .iter()
352 .take(requested)
353 .map(|m| *m as u64)
354 .sum();
355 println!("\nMax concurrent VM RAM: {total_mb}MB (host memory unknown)");
356 return requested.max(1);
357 }
358 };
359
360 let used_mb = mem.total_mb.saturating_sub(mem.available_mb);
361 println!(
362 "\nHost RAM: {}MB used / {}MB total ({}MB available, {}MB in swap)",
363 used_mb, mem.total_mb, mem.available_mb, mem.swap_used_mb
364 );
365
366 let budget = mem.available_mb.saturating_sub(HOST_RESERVE_MB);
367 let mut fit = 0usize;
368 let mut total = 0u64;
369 for m in sorted_mems_desc.iter().take(requested) {
370 let next = total + *m as u64;
371 if next > budget {
372 break;
373 }
374 total = next;
375 fit += 1;
376 }
377
378 let first_vm_mb = sorted_mems_desc.first().copied().unwrap_or(0) as u64;
379 if fit == 0 && first_vm_mb > 0 {
380 eprintln!(
383 "WARNING: largest VM needs {}MB but only {}MB free after {}MB host reserve. \
384 Running anyway at --parallel=1 — expect swap pressure. Close apps or lower \
385 VM size with --memory.",
386 first_vm_mb, budget, HOST_RESERVE_MB
387 );
388 fit = 1;
389 }
390
391 let clamped = fit.min(requested).max(1);
392 if clamped < requested {
393 eprintln!(
394 "Reducing --parallel from {requested} to {clamped} to fit in {budget}MB RAM budget \
395 (total host RAM {}MB, {}MB reserved for host)",
396 mem.total_mb, HOST_RESERVE_MB
397 );
398 }
399 println!("Max concurrent VM RAM: {total}MB (parallel={clamped})");
400 clamped
401}
402
403fn resolve_registry_path(explicit: Option<&PathBuf>) -> Result<PathBuf> {
405 if let Some(p) = explicit {
406 return std::fs::canonicalize(p)
407 .with_context(|| format!("registry path not found: {}", p.display()));
408 }
409
410 let candidates = [
411 PathBuf::from("registry"),
412 PathBuf::from("crates/ryra-core/registry"),
413 ];
414 for c in &candidates {
415 if c.exists() {
416 return std::fs::canonicalize(c)
417 .with_context(|| format!("failed to resolve {}", c.display()));
418 }
419 }
420
421 anyhow::bail!("no registry found. Pass --registry <path> or run from the repo root")
422}
423
424pub async fn run(args: Args) -> Result<()> {
426 install_signal_handler();
427
428 let registry_path = resolve_registry_path(args.registry.as_ref());
430
431 let mut discovered = Vec::new();
432
433 if let Some(ref project_dir) = args.project {
435 match registry::discover_local_project(project_dir)? {
436 Some(test) => discovered.push(test),
437 None => {
438 anyhow::bail!(
439 "no test.toml found in project directory: {}",
440 project_dir.display()
441 );
442 }
443 }
444 }
445
446 if let Ok(ref reg_path) = registry_path
448 && let Ok(reg_tests) = registry::discover(reg_path)
449 {
450 if args.project.is_none() {
452 discovered.extend(reg_tests);
453 }
454 }
455
456 let registry_path = registry_path.unwrap_or_else(|_| PathBuf::from("registry"));
458
459 if args.list {
460 let filtered: Vec<registry::DiscoveredTest> = if args.tests.is_empty() {
463 discovered
464 } else {
465 discovered
466 .into_iter()
467 .filter(|t| args.tests.iter().any(|f| t.name().contains(f.as_str())))
468 .collect()
469 };
470 render_list(&filtered, registry_path.as_path(), args.verbose);
471 return Ok(());
472 }
473
474 let keep_alive_interactive = args.keep_alive && args.tests.is_empty();
477
478 if discovered.is_empty() && !keep_alive_interactive {
479 anyhow::bail!("no tests found in registry at {}", registry_path.display());
480 }
481
482 let to_run: Vec<_> = if args.tests.is_empty() {
484 discovered.iter().collect()
485 } else {
486 discovered
487 .iter()
488 .filter(|t| args.tests.iter().any(|f| t.name().contains(f.as_str())))
489 .collect()
490 };
491
492 if to_run.is_empty() && !keep_alive_interactive {
493 anyhow::bail!("no tests matched the given filters");
494 }
495
496 reports::wipe_reports_dir()?;
498
499 if args.no_vm {
502 return run_bare(&args, &to_run, ®istry_path).await;
503 }
504
505 let use_kvm = !args.no_kvm;
506 ryra_vm::check_prerequisites(use_kvm)?;
507
508 let memory_override = args.memory;
509 let spawn_opts = std::sync::Arc::new(SpawnOpts {
510 use_kvm,
511 memory_mb: memory_override.unwrap_or(2048),
512 cpus: args.cpus,
513 disk_gb: 20,
514 });
515
516 let ryra_bin = match &args.ryra_bin {
517 Some(p) => std::fs::canonicalize(p)?,
520 None => {
521 let bin = find_ryra_binary()?;
522 ensure_binary_fresh(&bin)?;
523 bin
524 }
525 };
526
527 let max_memory: u32 = to_run
530 .iter()
531 .map(|t| memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, t)))
532 .max()
533 .unwrap_or(1024);
534
535 let base_image =
536 image::ensure_image(&args.distro, args.redownload, use_kvm, max_memory).await?;
537
538 if keep_alive_interactive {
539 return run_interactive_vm(&base_image, &spawn_opts, &ryra_bin, ®istry_path).await;
540 }
541
542 let base_image = std::sync::Arc::new(base_image);
543 let registry_path = std::sync::Arc::new(registry_path);
544
545 let any_needs_browser = to_run.iter().any(|t| t.needs_browser());
547 let browser_image = if any_needs_browser {
548 Some(std::sync::Arc::new(
549 image::ensure_browser_image(
550 &base_image,
551 &args.distro,
552 args.redownload,
553 use_kvm,
554 max_memory,
555 )
556 .await?,
557 ))
558 } else {
559 None
560 };
561
562 let mut all_images: Vec<String> = to_run
564 .iter()
565 .flat_map(|t| registry::images_for_test(®istry_path, t))
566 .collect();
567 all_images.sort();
568 all_images.dedup();
569
570 println!("Pre-caching {} container images...", all_images.len());
571 for img in &all_images {
572 machine::ensure_image_cached(img).await?;
573 }
574
575 let test_memories: Vec<(&str, u32)> = to_run
577 .iter()
578 .map(|t| {
579 let mem =
580 memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, t));
581 (t.name(), mem)
582 })
583 .collect();
584
585 let mut sorted_mems: Vec<u32> = test_memories.iter().map(|(_, m)| *m).collect();
586 sorted_mems.sort_unstable_by(|a, b| b.cmp(a));
587 let effective_parallel = plan_parallelism(args.parallel, &sorted_mems);
588 for (name, mem) in &test_memories {
589 println!(" {name}: {mem}MB");
590 }
591 println!(
592 "\nRunning {} tests (parallel={})\n",
593 to_run.len(),
594 effective_parallel
595 );
596
597 let wall_clock = std::time::Instant::now();
598 let semaphore = std::sync::Arc::new(Semaphore::new(effective_parallel));
599 let mut handles = vec![];
600 let total_tests = to_run.len();
601 let progress_done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
605 let progress_passed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
606
607 for test in to_run {
608 let permit = semaphore.clone().acquire_owned().await?;
609 let test_image: std::sync::Arc<image::Image> = if test.needs_browser() {
610 match browser_image.as_ref() {
611 Some(img) => img.clone(),
612 None => {
613 anyhow::bail!(
614 "test '{}' requires a browser image but none was prepared",
615 test.name()
616 );
617 }
618 }
619 } else {
620 base_image.clone()
621 };
622 let test_memory =
623 memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, test));
624 let test_disk = registry::vm_disk_for_test(®istry_path, test);
625 let spawn_opts = std::sync::Arc::new(SpawnOpts {
626 use_kvm,
627 memory_mb: test_memory,
628 cpus: args.cpus,
629 disk_gb: test_disk,
630 });
631 let ryra_bin = ryra_bin.clone();
632 let registry_path = registry_path.clone();
633 let keep_failed = args.keep_failed;
634 let keep_alive = args.keep_alive;
635 let verbose = args.verbose;
636 let single_test = total_tests == 1;
637 let name = test.name().to_string();
638 let has_quadlets = test.has_quadlets();
639 let progress_done = progress_done.clone();
640 let progress_passed = progress_passed.clone();
641 let quadlet_dir = match test {
643 registry::DiscoveredTest::Simple { setup, .. } => setup.quadlet_dir.clone(),
644 registry::DiscoveredTest::Lifecycle { .. } => None,
645 };
646
647 handles.push(tokio::spawn(async move {
648 let permit_guard = permit;
654 let id = machine::random_id();
655 let ssh_port = ports::allocate_ssh_port();
656 let start = std::time::Instant::now();
657 println!("[{name}] ---- VM START ryra-test-{id} (ssh port {ssh_port}, {test_memory}MB RAM) ----");
658
659 let result: ScenarioResult = async {
666 let fail_result = |msg: String| ScenarioResult {
667 name: name.clone(),
668 events: vec![],
669 duration: start.elapsed(),
670 outcome: scenario::Outcome::Failed(msg),
671 };
672
673 let test = if has_quadlets {
675 let qdir = match quadlet_dir.as_ref() {
676 Some(d) => d,
677 None => return fail_result("quadlet_dir must be set for quadlet tests".into()),
678 };
679 match registry::discover_local_project(qdir) {
680 Ok(Some(t)) => t,
681 Ok(None) => return fail_result("local project not found (internal error)".into()),
682 Err(e) => return fail_result(format!("local project discovery failed: {e:#}")),
683 }
684 } else {
685 let discovered = match registry::discover(®istry_path) {
686 Ok(d) => d,
687 Err(e) => return fail_result(format!("registry discovery failed: {e:#}")),
688 };
689 match discovered.into_iter().find(|t| t.name() == name) {
690 Some(t) => t,
691 None => return fail_result("test not found (internal error)".into()),
692 }
693 };
694
695 let phase = std::time::Instant::now();
697 println!("[{name}] booting VM...");
698 let vm = match Machine::spawn(&test_image, &id, ssh_port, &spawn_opts).await {
699 Ok(vm) => vm,
700 Err(e) => return fail_result(format!("failed to spawn VM: {e:#}")),
701 };
702 println!("[{name}] VM ready ({:.1}s)", phase.elapsed().as_secs_f64());
703
704 let phase = std::time::Instant::now();
706 if let Err(e) = machine::copy_ryra_to_vm(&vm, &ryra_bin).await {
707 let _ = vm.destroy().await;
708 return fail_result(format!("failed to copy ryra to VM: {e:#}"));
709 }
710
711 if registry_path.exists()
713 && let Err(e) = machine::copy_fixtures_to_vm(&vm, ®istry_path).await {
714 let _ = vm.destroy().await;
715 return fail_result(format!("failed to copy registry to VM: {e:#}"));
716 }
717
718 if let Some(ref qdir) = quadlet_dir
720 && let Err(e) = machine::copy_project_to_vm(&vm, qdir).await {
721 let _ = vm.destroy().await;
722 return fail_result(format!("failed to copy project to VM: {e:#}"));
723 }
724 println!("[{name}] files copied ({:.1}s)", phase.elapsed().as_secs_f64());
725
726 let images = registry::images_for_test(®istry_path, &test);
728 if !images.is_empty() {
729 let phase = std::time::Instant::now();
730 if let Err(e) = machine::load_images_into_vm(&vm, &images).await {
731 let _ = vm.destroy().await;
732 return fail_result(format!("failed to load container images: {e:#}"));
733 }
734 println!("[{name}] images loaded ({:.1}s, {} images)", phase.elapsed().as_secs_f64(), images.len());
735 }
736
737 let setup_time = start.elapsed();
738 println!("[{name}] running tests (setup took {:.1}s)...", setup_time.as_secs_f64());
739 let executor = crate::executor::VmExecutor::new(&vm);
740 let vm_registry = std::path::Path::new("/opt/ryra-test-registry");
741 let result = match &test {
742 registry::DiscoveredTest::Lifecycle { steps, .. } => {
743 runner::run_lifecycle_test(&executor, &name, steps, verbose, single_test, vm_registry, false).await
744 }
745 registry::DiscoveredTest::Simple { .. } => {
746 runner::run_registry_test(&executor, &test).await
747 }
748 };
749
750 if !result.passed() {
752 let serial_log = vm.work_dir.join("serial.log");
753 if let Ok(content) = tokio::fs::read_to_string(&serial_log).await {
754 let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
755 let fail_log_dir = workspace_root.join("crates/ryra-test/logs");
756 let _ = tokio::fs::create_dir_all(&fail_log_dir).await;
757 let dest = fail_log_dir.join(format!("{name}-serial.log"));
758 let _ = tokio::fs::write(&dest, &content).await;
759 eprintln!("[{name}] serial log saved to: {}", dest.display());
760
761 if verbose {
762 let lines: Vec<&str> = content.lines().collect();
763 let start_idx = lines.len().saturating_sub(50);
764 eprintln!("[{name}] --- serial log (last 50 lines) ---");
765 for line in &lines[start_idx..] {
766 eprintln!(" {line}");
767 }
768 eprintln!("[{name}] --- end serial log ---");
769 }
770 }
771 }
772
773 let should_keep = keep_alive || (keep_failed && !result.passed());
775 if should_keep {
776 println!("[{name}] keeping VM alive:");
777 vm.keep_alive();
778 } else if let Err(e) = vm.destroy().await {
779 eprintln!("[{name}] warning: failed to destroy VM: {e}");
780 }
781
782 result
783 }
784 .await;
785
786 use std::sync::atomic::Ordering;
790 let done = progress_done.fetch_add(1, Ordering::SeqCst) + 1;
791 if result.passed() {
792 progress_passed.fetch_add(1, Ordering::SeqCst);
793 }
794 let passed_so_far = progress_passed.load(Ordering::SeqCst);
795 let failed_so_far = done - passed_so_far;
796 let wall = wall_clock.elapsed().as_secs();
797 let (mins, secs) = (wall / 60, wall % 60);
798 let status = match &result.outcome {
799 scenario::Outcome::Passed => "PASS".to_string(),
800 scenario::Outcome::Skipped => "SKIP".to_string(),
801 scenario::Outcome::Failed(msg) => {
802 let first = msg.lines().next().unwrap_or("");
803 let trimmed: String = first.chars().take(140).collect();
804 if first.chars().count() > 140 {
805 format!("FAIL: {trimmed}…")
806 } else {
807 format!("FAIL: {trimmed}")
808 }
809 }
810 };
811 println!(
812 "[{name}] ---- VM END ({status}, test {:.1}s) ---- \
813 [{done}/{total_tests} · {passed_so_far} pass · {failed_so_far} fail · \
814 total {mins}:{secs:02}]",
815 start.elapsed().as_secs_f64()
816 );
817 drop(permit_guard); result
819 }));
820 }
821
822 let mut results = vec![];
823 for handle in handles {
824 results.push(handle.await?);
825 }
826
827 print_summary(&results, wall_clock.elapsed());
828 save_results(&results)?;
829
830 if results.iter().any(|r| !r.passed()) {
831 std::process::exit(1);
832 }
833
834 Ok(())
835}
836
837async fn run_interactive_vm(
839 base_image: &image::Image,
840 spawn_opts: &SpawnOpts,
841 ryra_bin: &Path,
842 registry_path: &Path,
843) -> Result<()> {
844 let id = machine::random_id();
845 let ssh_port = ports::allocate_ssh_port();
846
847 println!("Booting interactive VM ryra-test-{id} (ssh port {ssh_port})...");
848 let vm = Machine::spawn(base_image, &id, ssh_port, spawn_opts).await?;
849 println!("VM ready.");
850
851 println!("Copying ryra binary...");
852 machine::copy_ryra_to_vm(&vm, ryra_bin).await?;
853
854 println!("Copying registry...");
855 machine::copy_fixtures_to_vm(&vm, registry_path).await?;
856
857 println!("\nVM is ready. Connect with:\n");
858 println!(
859 " ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
860 -i {}/id_ed25519 -p {} ryra@{}",
861 vm.work_dir.display(),
862 vm.ssh_port,
863 vm.ssh_host,
864 );
865 println!("\nRegistry is at /opt/ryra-test-registry in the VM.");
866 println!("Press Ctrl-C to stop the VM.\n");
867
868 tokio::signal::ctrl_c().await?;
869
870 println!("\nShutting down VM...");
871 vm.destroy().await?;
872 Ok(())
873}
874
875async fn reset_bare_state(executor: &crate::executor::LocalExecutor) {
879 use crate::executor::Executor;
880 let _ = executor.exec("ryra reset -y").await;
881 let _ = executor
882 .exec("rm -rf \"${XDG_CACHE_HOME:-$HOME/.cache}/services/default\"")
883 .await;
884}
885
886async fn run_bare(
888 args: &Args,
889 to_run: &[®istry::DiscoveredTest],
890 registry_path: &Path,
891) -> Result<()> {
892 let wall_clock = std::time::Instant::now();
893 let executor = crate::executor::LocalExecutor::with_registry(registry_path);
894 let mut results = Vec::new();
895 let single_test = to_run.len() == 1;
896
897 println!("\nRunning {} tests on host (bare mode)\n", to_run.len());
898
899 for test in to_run {
900 let name = test.name().to_string();
901 println!("---- START {name} (bare) ----");
902
903 reset_bare_state(&executor).await;
910
911 let start = std::time::Instant::now();
912 let result = match test {
913 registry::DiscoveredTest::Lifecycle { steps, .. } => {
914 runner::run_lifecycle_test(
915 &executor,
916 &name,
917 steps,
918 args.verbose,
919 single_test,
920 registry_path,
921 args.retest,
922 )
923 .await
924 }
925 registry::DiscoveredTest::Simple { .. } => {
926 runner::run_registry_test(&executor, test).await
927 }
928 };
929
930 let status = if result.passed() { "PASS" } else { "FAIL" };
931 println!(
932 "---- END {name} ({status}, {:.1}s) ----",
933 start.elapsed().as_secs_f64()
934 );
935 results.push(result);
936 }
937
938 print_summary(&results, wall_clock.elapsed());
939 save_results(&results)?;
940
941 if results.iter().any(|r| !r.passed()) {
942 std::process::exit(1);
943 }
944
945 Ok(())
946}