Skip to main content

ryra_test/
lib.rs

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
19/// Install a Ctrl-C handler that kills all active VMs and exits.
20fn install_signal_handler() {
21    // We use the raw libc handler (not tokio::signal) so it works even if
22    // the tokio runtime is blocked or mid-shutdown.
23    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    // Write to stderr manually (signal-safe)
33    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); // 128 + SIGINT
39}
40
41/// Render `--list` output. Two sections:
42///  1. **Service tests** — grouped under the owning service name
43///     (derived from `registry/<svc>/test.toml`).
44///  2. **Service-agnostic tests** — flat list from `registry/tests/*.toml`.
45///
46/// Each line shows the test name, step count, `[browser]` flag, and
47/// distinct step kinds so `playwright`/`shell`/`http` tell you what
48/// the test does at a glance.
49///
50/// When `verbose` is set, each test also gets a breakdown of every step
51/// (commands, URLs, polls, heredoc bodies) so the caller can see exactly
52/// what the test runs without opening the `.toml`.
53fn 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    // Group service tests by owning directory name; keep cross-cutting
63    // tests flat since each file already contains a single test.
64    let mut service_groups: Vec<(String, Vec<&registry::DiscoveredTest>)> = Vec::new();
65    let mut cross_cutting: Vec<&registry::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: &registry::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        // Verbose: print each step's details. Use a deeper indent so the
105        // hierarchy (group → test → step lines) stays readable.
106        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    /// Max concurrent VMs
157    #[arg(long, default_value_t = 1)]
158    pub parallel: usize,
159
160    /// Base image distro
161    #[arg(long, default_value_t = Distro::Debian13)]
162    pub distro: Distro,
163
164    /// Re-download the base cloud image
165    #[arg(long)]
166    pub redownload: bool,
167
168    /// Path to ryra binary
169    #[arg(long)]
170    pub ryra_bin: Option<PathBuf>,
171
172    /// Don't destroy VMs for failed tests (for debugging via SSH)
173    #[arg(long)]
174    pub keep_failed: bool,
175
176    /// Keep VM alive after tests complete (or boot without running tests).
177    /// Prints SSH connection command for interactive use.
178    #[arg(long)]
179    pub keep_alive: bool,
180
181    /// Disable KVM acceleration (use software emulation — slower)
182    #[arg(long)]
183    pub no_kvm: bool,
184
185    /// Run tests directly on the host without a VM
186    #[arg(long)]
187    pub no_vm: bool,
188
189    /// Skip setup steps (add/wait/remove/reset) and only run shell/playwright
190    /// steps. Use to re-run tests quickly when services are already installed.
191    #[arg(long)]
192    pub retest: bool,
193
194    /// VM memory in MB (overrides auto-detection from service requirements)
195    #[arg(long)]
196    pub memory: Option<u32>,
197
198    /// VM CPU count
199    #[arg(long, default_value_t = 2)]
200    pub cpus: u32,
201
202    /// Show serial log output on failure
203    #[arg(long, short)]
204    pub verbose: bool,
205
206    /// Path to registry directory (auto-detected if omitted)
207    #[arg(long)]
208    pub registry: Option<PathBuf>,
209
210    /// Path to a local project directory with test.toml (+ optional quadlet files)
211    #[arg(long)]
212    pub project: Option<PathBuf>,
213
214    /// List available tests
215    #[arg(long)]
216    pub list: bool,
217
218    /// Test names to run (runs all if empty, supports substring match)
219    pub tests: Vec<String>,
220}
221
222fn find_ryra_binary() -> Result<PathBuf> {
223    // The currently running binary is the one being tested — `ryra test` is a
224    // subcommand of `ryra` itself, so whichever binary the user launched is by
225    // definition the one we want to copy into VMs. Using current_exe avoids the
226    // old footgun where we'd silently prefer target/release/ryra even when the
227    // user had just rebuilt debug.
228    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
233/// Walk `crates/` looking for any `.rs` or `Cargo.toml` newer than `binary`.
234/// Returns the newest offending source file, if any. Cheap (~few ms for <1000
235/// files) because we only stat metadata, not read contents.
236fn 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        // Running outside the workspace (e.g. an installed binary) — no check.
245        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                // Skip build output dirs — they contain generated files we don't care about.
271                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
293/// Error out if the `ryra` binary we're about to ship into VMs is older than
294/// any workspace source file. This is the stale-binary footgun: `cargo build -p
295/// ryra-test` rebuilds the lib but leaves `target/release/ryra` untouched, so
296/// tests silently run against old behavior.
297fn 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
338/// Safety margin (MB) kept free beyond the VMs' own needs — for host processes,
339/// QEMU overhead, the kernel page cache, and the GPU compositor. Running this
340/// tight causes kernel-level thrashing and on Asahi can freeze the display.
341const HOST_RESERVE_MB: u64 = 1024;
342
343/// Decide how many VMs can safely run in parallel given current host memory.
344/// Returns the clamped parallel count (never more than `requested`, never below 1),
345/// and prints a report. Uses `sorted_mems_desc` so we pack the largest VMs first.
346fn 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        // Even one VM doesn't fit in budget — warn loudly but still let it run at
381        // parallel=1 so the user can choose to override with --memory.
382        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
403/// Find the registry path — explicit arg, or auto-detect.
404fn 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
424/// Run the E2E test suite with the given arguments.
425pub async fn run(args: Args) -> Result<()> {
426    install_signal_handler();
427
428    // Check for local project first, then fall back to registry
429    let registry_path = resolve_registry_path(args.registry.as_ref());
430
431    let mut discovered = Vec::new();
432
433    // Discover local project tests (--project flag)
434    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    // Discover registry tests (only if no explicit --project or if registry is also available)
447    if let Ok(ref reg_path) = registry_path
448        && let Ok(reg_tests) = registry::discover(reg_path)
449    {
450        // If --project was explicitly passed, skip registry tests
451        if args.project.is_none() {
452            discovered.extend(reg_tests);
453        }
454    }
455
456    // Need a registry path for dependency resolution even with local projects
457    let registry_path = registry_path.unwrap_or_else(|_| PathBuf::from("registry"));
458
459    if args.list {
460        // Respect positional filters: `ryra test --list whoami` shows only
461        // whoami tests. Same substring-contains semantics as the run path.
462        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    // --keep-alive with no tests: boot a VM and block until Ctrl-C.
475    // This path needs VM prerequisites, so handle it after the no-vm branch below.
476    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    // Filter tests (independent of VM prep — safe to do first)
483    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    // Fresh report directory for this run. Previous run's output is discarded.
497    reports::wipe_reports_dir()?;
498
499    // --no-vm: run entirely on the host. Skip all VM prerequisites, binary
500    // lookup, and image preparation since none of it is needed in bare mode.
501    if args.no_vm {
502        return run_bare(&args, &to_run, &registry_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        // Explicit --ryra-bin: trust the user, don't check freshness (the path
518        // may be from a different tree, CI artefact, etc.).
519        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    // Compute max RAM needed across the tests we're actually running.
528    // The snapshot must be created at this size so all VMs can restore from it.
529    let max_memory: u32 = to_run
530        .iter()
531        .map(|t| memory_override.unwrap_or_else(|| registry::vm_memory_for_test(&registry_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, &registry_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    // Prepare browser image only if a filtered test actually needs it
546    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    // Pre-pull all container images before spawning VMs.
563    let mut all_images: Vec<String> = to_run
564        .iter()
565        .flat_map(|t| registry::images_for_test(&registry_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    // Compute per-test memory first (needed for accurate parallelism calculation)
576    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(&registry_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    // Shared progress counters — each task increments these when its VM
602    // ends so the tail of the output doubles as a live progress ticker
603    // (works under --parallel, order-independent).
604    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(&registry_path, test));
624        let test_disk = registry::vm_disk_for_test(&registry_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        // Extract quadlet_dir before spawning task (DiscoveredTest isn't Send)
642        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            // `permit` holds a slot in the `--parallel` semaphore; must be
649            // alive until the task finishes. Kept as an explicit local so
650            // Drop order is obvious to readers (and to the compiler —
651            // `let _x = ...` used to be load-bearing here; drop at end
652            // via explicit bind + final drop avoids any NLL surprises).
653            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            // All fallible work lives in an inner async block so every exit
660            // path — including early returns for VM-boot or file-copy failures —
661            // flows through the single VM END reporting block below. Without
662            // this, a `return fail_result(...)` would skip the VM END print and
663            // the user would see back-to-back VM STARTs with no indication of
664            // what went wrong on the previous test.
665            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                // Re-discover tests inside task (DiscoveredTest isn't Send due to lifetime)
674                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(&registry_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                // Spawn VM
696                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                // Copy ryra binary into VM
705                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                // Copy registry into VM (needed for dependency resolution)
712                if registry_path.exists()
713                    && let Err(e) = machine::copy_fixtures_to_vm(&vm, &registry_path).await {
714                        let _ = vm.destroy().await;
715                        return fail_result(format!("failed to copy registry to VM: {e:#}"));
716                    }
717
718                // Copy quadlet project files into VM
719                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                // Load cached container images into VM
727                let images = registry::images_for_test(&registry_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                // On failure, save serial log to logs dir
751                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                // Decide whether to keep the VM alive
774                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            // Single end-of-task reporting path — runs for every outcome above,
787            // so the user always sees a VM END line (with the failure reason
788            // for fails) before the next test's VM START prints.
789            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); // release the --parallel slot AFTER reporting
818            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
837/// Boot a VM with ryra + registry installed, print SSH command, block until Ctrl-C.
838async 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
875/// Clean host state between bare-mode tests: uninstall all ryra-managed
876/// services and remove the default-registry cache (which can be polluted by
877/// tests like diff-whoami that intentionally mutate it).
878async 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
886/// Run tests directly on the host without a VM.
887async fn run_bare(
888    args: &Args,
889    to_run: &[&registry::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 host state between tests so one test's leftover services or
904        // cache pollution doesn't cascade into the next. Bare mode shares the
905        // host's ryra config/cache across all tests — unlike VM mode where
906        // each test gets a fresh VM. Failures here are non-fatal: the first
907        // reset may find nothing to clean, and test assertions will surface
908        // any real setup failures.
909        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}