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("crates/ryra-core/registry"),
412 PathBuf::from("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 =
458 registry_path.unwrap_or_else(|_| PathBuf::from("crates/ryra-core/registry"));
459
460 if args.list {
461 let filtered: Vec<registry::DiscoveredTest> = if args.tests.is_empty() {
464 discovered
465 } else {
466 discovered
467 .into_iter()
468 .filter(|t| args.tests.iter().any(|f| t.name().contains(f.as_str())))
469 .collect()
470 };
471 render_list(&filtered, registry_path.as_path(), args.verbose);
472 return Ok(());
473 }
474
475 let keep_alive_interactive = args.keep_alive && args.tests.is_empty();
478
479 if discovered.is_empty() && !keep_alive_interactive {
480 anyhow::bail!("no tests found in registry at {}", registry_path.display());
481 }
482
483 let to_run: Vec<_> = if args.tests.is_empty() {
485 discovered.iter().collect()
486 } else {
487 discovered
488 .iter()
489 .filter(|t| args.tests.iter().any(|f| t.name().contains(f.as_str())))
490 .collect()
491 };
492
493 if to_run.is_empty() && !keep_alive_interactive {
494 anyhow::bail!("no tests matched the given filters");
495 }
496
497 reports::wipe_reports_dir()?;
499
500 if args.no_vm {
503 return run_bare(&args, &to_run, ®istry_path).await;
504 }
505
506 let use_kvm = !args.no_kvm;
507 ryra_vm::check_prerequisites(use_kvm)?;
508
509 let memory_override = args.memory;
510 let spawn_opts = std::sync::Arc::new(SpawnOpts {
511 use_kvm,
512 memory_mb: memory_override.unwrap_or(2048),
513 cpus: args.cpus,
514 disk_gb: 20,
515 });
516
517 let ryra_bin = match &args.ryra_bin {
518 Some(p) => std::fs::canonicalize(p)?,
521 None => {
522 let bin = find_ryra_binary()?;
523 ensure_binary_fresh(&bin)?;
524 bin
525 }
526 };
527
528 let max_memory: u32 = to_run
531 .iter()
532 .map(|t| memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, t)))
533 .max()
534 .unwrap_or(1024);
535
536 let base_image =
537 image::ensure_image(&args.distro, args.redownload, use_kvm, max_memory).await?;
538
539 if keep_alive_interactive {
540 return run_interactive_vm(&base_image, &spawn_opts, &ryra_bin, ®istry_path).await;
541 }
542
543 let base_image = std::sync::Arc::new(base_image);
544 let registry_path = std::sync::Arc::new(registry_path);
545
546 let any_needs_browser = to_run.iter().any(|t| t.needs_browser());
548 let browser_image = if any_needs_browser {
549 Some(std::sync::Arc::new(
550 image::ensure_browser_image(
551 &base_image,
552 &args.distro,
553 args.redownload,
554 use_kvm,
555 max_memory,
556 )
557 .await?,
558 ))
559 } else {
560 None
561 };
562
563 let mut all_images: Vec<String> = to_run
565 .iter()
566 .flat_map(|t| registry::images_for_test(®istry_path, t))
567 .collect();
568 all_images.sort();
569 all_images.dedup();
570
571 println!("Pre-caching {} container images...", all_images.len());
572 for img in &all_images {
573 machine::ensure_image_cached(img).await?;
574 }
575
576 let test_memories: Vec<(&str, u32)> = to_run
578 .iter()
579 .map(|t| {
580 let mem =
581 memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, t));
582 (t.name(), mem)
583 })
584 .collect();
585
586 let mut sorted_mems: Vec<u32> = test_memories.iter().map(|(_, m)| *m).collect();
587 sorted_mems.sort_unstable_by(|a, b| b.cmp(a));
588 let effective_parallel = plan_parallelism(args.parallel, &sorted_mems);
589 for (name, mem) in &test_memories {
590 println!(" {name}: {mem}MB");
591 }
592 println!(
593 "\nRunning {} tests (parallel={})\n",
594 to_run.len(),
595 effective_parallel
596 );
597
598 let wall_clock = std::time::Instant::now();
599 let semaphore = std::sync::Arc::new(Semaphore::new(effective_parallel));
600 let mut handles = vec![];
601 let total_tests = to_run.len();
602 let progress_done = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
606 let progress_passed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
607
608 for test in to_run {
609 let permit = semaphore.clone().acquire_owned().await?;
610 let test_image: std::sync::Arc<image::Image> = if test.needs_browser() {
611 match browser_image.as_ref() {
612 Some(img) => img.clone(),
613 None => {
614 anyhow::bail!(
615 "test '{}' requires a browser image but none was prepared",
616 test.name()
617 );
618 }
619 }
620 } else {
621 base_image.clone()
622 };
623 let test_memory =
624 memory_override.unwrap_or_else(|| registry::vm_memory_for_test(®istry_path, test));
625 let test_disk = registry::vm_disk_for_test(®istry_path, test);
626 let spawn_opts = std::sync::Arc::new(SpawnOpts {
627 use_kvm,
628 memory_mb: test_memory,
629 cpus: args.cpus,
630 disk_gb: test_disk,
631 });
632 let ryra_bin = ryra_bin.clone();
633 let registry_path = registry_path.clone();
634 let keep_failed = args.keep_failed;
635 let keep_alive = args.keep_alive;
636 let verbose = args.verbose;
637 let single_test = total_tests == 1;
638 let name = test.name().to_string();
639 let has_quadlets = test.has_quadlets();
640 let progress_done = progress_done.clone();
641 let progress_passed = progress_passed.clone();
642 let quadlet_dir = match test {
644 registry::DiscoveredTest::Simple { setup, .. } => setup.quadlet_dir.clone(),
645 registry::DiscoveredTest::Lifecycle { .. } => None,
646 };
647
648 handles.push(tokio::spawn(async move {
649 let permit_guard = permit;
655 let id = machine::random_id();
656 let ssh_port = ports::allocate_ssh_port();
657 let start = std::time::Instant::now();
658 println!("[{name}] ---- VM START ryra-test-{id} (ssh port {ssh_port}, {test_memory}MB RAM) ----");
659
660 let result: ScenarioResult = async {
667 let fail_result = |msg: String| ScenarioResult {
668 name: name.clone(),
669 events: vec![],
670 duration: start.elapsed(),
671 outcome: scenario::Outcome::Failed(msg),
672 };
673
674 let test = if has_quadlets {
676 let qdir = match quadlet_dir.as_ref() {
677 Some(d) => d,
678 None => return fail_result("quadlet_dir must be set for quadlet tests".into()),
679 };
680 match registry::discover_local_project(qdir) {
681 Ok(Some(t)) => t,
682 Ok(None) => return fail_result("local project not found (internal error)".into()),
683 Err(e) => return fail_result(format!("local project discovery failed: {e:#}")),
684 }
685 } else {
686 let discovered = match registry::discover(®istry_path) {
687 Ok(d) => d,
688 Err(e) => return fail_result(format!("registry discovery failed: {e:#}")),
689 };
690 match discovered.into_iter().find(|t| t.name() == name) {
691 Some(t) => t,
692 None => return fail_result("test not found (internal error)".into()),
693 }
694 };
695
696 let phase = std::time::Instant::now();
698 println!("[{name}] booting VM...");
699 let vm = match Machine::spawn(&test_image, &id, ssh_port, &spawn_opts).await {
700 Ok(vm) => vm,
701 Err(e) => return fail_result(format!("failed to spawn VM: {e:#}")),
702 };
703 println!("[{name}] VM ready ({:.1}s)", phase.elapsed().as_secs_f64());
704
705 let phase = std::time::Instant::now();
707 if let Err(e) = machine::copy_ryra_to_vm(&vm, &ryra_bin).await {
708 let _ = vm.destroy().await;
709 return fail_result(format!("failed to copy ryra to VM: {e:#}"));
710 }
711
712 if registry_path.exists()
714 && let Err(e) = machine::copy_fixtures_to_vm(&vm, ®istry_path).await {
715 let _ = vm.destroy().await;
716 return fail_result(format!("failed to copy registry to VM: {e:#}"));
717 }
718
719 if let Some(ref qdir) = quadlet_dir
721 && let Err(e) = machine::copy_project_to_vm(&vm, qdir).await {
722 let _ = vm.destroy().await;
723 return fail_result(format!("failed to copy project to VM: {e:#}"));
724 }
725 println!("[{name}] files copied ({:.1}s)", phase.elapsed().as_secs_f64());
726
727 let images = registry::images_for_test(®istry_path, &test);
729 if !images.is_empty() {
730 let phase = std::time::Instant::now();
731 if let Err(e) = machine::load_images_into_vm(&vm, &images).await {
732 let _ = vm.destroy().await;
733 return fail_result(format!("failed to load container images: {e:#}"));
734 }
735 println!("[{name}] images loaded ({:.1}s, {} images)", phase.elapsed().as_secs_f64(), images.len());
736 }
737
738 let setup_time = start.elapsed();
739 println!("[{name}] running tests (setup took {:.1}s)...", setup_time.as_secs_f64());
740 let executor = crate::executor::VmExecutor::new(&vm);
741 let vm_registry = std::path::Path::new("/opt/ryra-test-registry");
742 let result = match &test {
743 registry::DiscoveredTest::Lifecycle { steps, .. } => {
744 runner::run_lifecycle_test(&executor, &name, steps, verbose, single_test, vm_registry, false).await
745 }
746 registry::DiscoveredTest::Simple { .. } => {
747 runner::run_registry_test(&executor, &test).await
748 }
749 };
750
751 if !result.passed() {
753 let serial_log = vm.work_dir.join("serial.log");
754 if let Ok(content) = tokio::fs::read_to_string(&serial_log).await {
755 let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
756 let fail_log_dir = workspace_root.join("crates/ryra-test/logs");
757 let _ = tokio::fs::create_dir_all(&fail_log_dir).await;
758 let dest = fail_log_dir.join(format!("{name}-serial.log"));
759 let _ = tokio::fs::write(&dest, &content).await;
760 eprintln!("[{name}] serial log saved to: {}", dest.display());
761
762 if verbose {
763 let lines: Vec<&str> = content.lines().collect();
764 let start_idx = lines.len().saturating_sub(50);
765 eprintln!("[{name}] --- serial log (last 50 lines) ---");
766 for line in &lines[start_idx..] {
767 eprintln!(" {line}");
768 }
769 eprintln!("[{name}] --- end serial log ---");
770 }
771 }
772 }
773
774 let should_keep = keep_alive || (keep_failed && !result.passed());
776 if should_keep {
777 println!("[{name}] keeping VM alive:");
778 vm.keep_alive();
779 } else if let Err(e) = vm.destroy().await {
780 eprintln!("[{name}] warning: failed to destroy VM: {e}");
781 }
782
783 result
784 }
785 .await;
786
787 use std::sync::atomic::Ordering;
791 let done = progress_done.fetch_add(1, Ordering::SeqCst) + 1;
792 if result.passed() {
793 progress_passed.fetch_add(1, Ordering::SeqCst);
794 }
795 let passed_so_far = progress_passed.load(Ordering::SeqCst);
796 let failed_so_far = done - passed_so_far;
797 let wall = wall_clock.elapsed().as_secs();
798 let (mins, secs) = (wall / 60, wall % 60);
799 let status = match &result.outcome {
800 scenario::Outcome::Passed => "PASS".to_string(),
801 scenario::Outcome::Skipped => "SKIP".to_string(),
802 scenario::Outcome::Failed(msg) => {
803 let first = msg.lines().next().unwrap_or("");
804 let trimmed: String = first.chars().take(140).collect();
805 if first.chars().count() > 140 {
806 format!("FAIL: {trimmed}…")
807 } else {
808 format!("FAIL: {trimmed}")
809 }
810 }
811 };
812 println!(
813 "[{name}] ---- VM END ({status}, test {:.1}s) ---- \
814 [{done}/{total_tests} · {passed_so_far} pass · {failed_so_far} fail · \
815 total {mins}:{secs:02}]",
816 start.elapsed().as_secs_f64()
817 );
818 drop(permit_guard); result
820 }));
821 }
822
823 let mut results = vec![];
824 for handle in handles {
825 results.push(handle.await?);
826 }
827
828 print_summary(&results, wall_clock.elapsed());
829 save_results(&results)?;
830
831 if results.iter().any(|r| !r.passed()) {
832 std::process::exit(1);
833 }
834
835 Ok(())
836}
837
838async fn run_interactive_vm(
840 base_image: &image::Image,
841 spawn_opts: &SpawnOpts,
842 ryra_bin: &Path,
843 registry_path: &Path,
844) -> Result<()> {
845 let id = machine::random_id();
846 let ssh_port = ports::allocate_ssh_port();
847
848 println!("Booting interactive VM ryra-test-{id} (ssh port {ssh_port})...");
849 let vm = Machine::spawn(base_image, &id, ssh_port, spawn_opts).await?;
850 println!("VM ready.");
851
852 println!("Copying ryra binary...");
853 machine::copy_ryra_to_vm(&vm, ryra_bin).await?;
854
855 println!("Copying registry...");
856 machine::copy_fixtures_to_vm(&vm, registry_path).await?;
857
858 println!("\nVM is ready. Connect with:\n");
859 println!(
860 " ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
861 -i {}/id_ed25519 -p {} ryra@{}",
862 vm.work_dir.display(),
863 vm.ssh_port,
864 vm.ssh_host,
865 );
866 println!("\nRegistry is at /opt/ryra-test-registry in the VM.");
867 println!("Press Ctrl-C to stop the VM.\n");
868
869 tokio::signal::ctrl_c().await?;
870
871 println!("\nShutting down VM...");
872 vm.destroy().await?;
873 Ok(())
874}
875
876async fn reset_bare_state(executor: &crate::executor::LocalExecutor) {
880 use crate::executor::Executor;
881 let _ = executor.exec("ryra reset -y").await;
882 let _ = executor
883 .exec("rm -rf \"${XDG_CACHE_HOME:-$HOME/.cache}/ryra/bundled\"")
884 .await;
885}
886
887async fn run_bare(
889 args: &Args,
890 to_run: &[®istry::DiscoveredTest],
891 registry_path: &Path,
892) -> Result<()> {
893 let wall_clock = std::time::Instant::now();
894 let executor = crate::executor::LocalExecutor;
895 let mut results = Vec::new();
896 let single_test = to_run.len() == 1;
897
898 println!("\nRunning {} tests on host (bare mode)\n", to_run.len());
899
900 for test in to_run {
901 let name = test.name().to_string();
902 println!("---- START {name} (bare) ----");
903
904 reset_bare_state(&executor).await;
911
912 let start = std::time::Instant::now();
913 let result = match test {
914 registry::DiscoveredTest::Lifecycle { steps, .. } => {
915 runner::run_lifecycle_test(
916 &executor,
917 &name,
918 steps,
919 args.verbose,
920 single_test,
921 registry_path,
922 args.retest,
923 )
924 .await
925 }
926 registry::DiscoveredTest::Simple { .. } => {
927 runner::run_registry_test(&executor, test).await
928 }
929 };
930
931 let status = if result.passed() { "PASS" } else { "FAIL" };
932 println!(
933 "---- END {name} ({status}, {:.1}s) ----",
934 start.elapsed().as_secs_f64()
935 );
936 results.push(result);
937 }
938
939 print_summary(&results, wall_clock.elapsed());
940 save_results(&results)?;
941
942 if results.iter().any(|r| !r.passed()) {
943 std::process::exit(1);
944 }
945
946 Ok(())
947}