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("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
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 =
458        registry_path.unwrap_or_else(|_| PathBuf::from("crates/ryra-core/registry"));
459
460    if args.list {
461        // Respect positional filters: `ryra test --list whoami` shows only
462        // whoami tests. Same substring-contains semantics as the run path.
463        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    // --keep-alive with no tests: boot a VM and block until Ctrl-C.
476    // This path needs VM prerequisites, so handle it after the no-vm branch below.
477    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    // Filter tests (independent of VM prep — safe to do first)
484    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    // Fresh report directory for this run. Previous run's output is discarded.
498    reports::wipe_reports_dir()?;
499
500    // --no-vm: run entirely on the host. Skip all VM prerequisites, binary
501    // lookup, and image preparation since none of it is needed in bare mode.
502    if args.no_vm {
503        return run_bare(&args, &to_run, &registry_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        // Explicit --ryra-bin: trust the user, don't check freshness (the path
519        // may be from a different tree, CI artefact, etc.).
520        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    // Compute max RAM needed across the tests we're actually running.
529    // The snapshot must be created at this size so all VMs can restore from it.
530    let max_memory: u32 = to_run
531        .iter()
532        .map(|t| memory_override.unwrap_or_else(|| registry::vm_memory_for_test(&registry_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, &registry_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    // Prepare browser image only if a filtered test actually needs it
547    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    // Pre-pull all container images before spawning VMs.
564    let mut all_images: Vec<String> = to_run
565        .iter()
566        .flat_map(|t| registry::images_for_test(&registry_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    // Compute per-test memory first (needed for accurate parallelism calculation)
577    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(&registry_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    // Shared progress counters — each task increments these when its VM
603    // ends so the tail of the output doubles as a live progress ticker
604    // (works under --parallel, order-independent).
605    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(&registry_path, test));
625        let test_disk = registry::vm_disk_for_test(&registry_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        // Extract quadlet_dir before spawning task (DiscoveredTest isn't Send)
643        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            // `permit` holds a slot in the `--parallel` semaphore; must be
650            // alive until the task finishes. Kept as an explicit local so
651            // Drop order is obvious to readers (and to the compiler —
652            // `let _x = ...` used to be load-bearing here; drop at end
653            // via explicit bind + final drop avoids any NLL surprises).
654            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            // All fallible work lives in an inner async block so every exit
661            // path — including early returns for VM-boot or file-copy failures —
662            // flows through the single VM END reporting block below. Without
663            // this, a `return fail_result(...)` would skip the VM END print and
664            // the user would see back-to-back VM STARTs with no indication of
665            // what went wrong on the previous test.
666            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                // Re-discover tests inside task (DiscoveredTest isn't Send due to lifetime)
675                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(&registry_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                // Spawn VM
697                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                // Copy ryra binary into VM
706                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                // Copy registry into VM (needed for dependency resolution)
713                if registry_path.exists()
714                    && let Err(e) = machine::copy_fixtures_to_vm(&vm, &registry_path).await {
715                        let _ = vm.destroy().await;
716                        return fail_result(format!("failed to copy registry to VM: {e:#}"));
717                    }
718
719                // Copy quadlet project files into VM
720                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                // Load cached container images into VM
728                let images = registry::images_for_test(&registry_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                // On failure, save serial log to logs dir
752                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                // Decide whether to keep the VM alive
775                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            // Single end-of-task reporting path — runs for every outcome above,
788            // so the user always sees a VM END line (with the failure reason
789            // for fails) before the next test's VM START prints.
790            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); // release the --parallel slot AFTER reporting
819            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
838/// Boot a VM with ryra + registry installed, print SSH command, block until Ctrl-C.
839async 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
876/// Clean host state between bare-mode tests: uninstall all ryra-managed
877/// services and remove the bundled registry cache (which can be polluted by
878/// tests like diff-whoami that intentionally mutate it).
879async 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
887/// Run tests directly on the host without a VM.
888async fn run_bare(
889    args: &Args,
890    to_run: &[&registry::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 host state between tests so one test's leftover services or
905        // cache pollution doesn't cascade into the next. Bare mode shares the
906        // host's ryra config/cache across all tests — unlike VM mode where
907        // each test gets a fresh VM. Failures here are non-fatal: the first
908        // reset may find nothing to clean, and test assertions will surface
909        // any real setup failures.
910        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}