//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport. use anyhow::{Result, bail}; use sausage::{ Host, runtime::{Exit, InstanceConfig, Runtime}, }; use serde_json::{Value, json}; use std::{ path::{Path, PathBuf}, sync::Arc, }; use wasmtime::component::Component; /// A temporary world remains alive through the end of its WASI store. struct TestWorld(PathBuf); impl Drop for TestWorld { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } struct Run { exit: Exit, output: String, } /// Runs one instance and separates JSON-RPC requests from ordinary test output. async fn instance( runtime: Arc, component: Component, args: Vec, package: PathBuf, ) -> Result { let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7())); let world = TestWorld(path); let host = Host::test_world(&world.0, runtime.clone()).await?; let tracker = sausage_vfs::Process::default(); let mut filesystem = host.filesystem().await?; // Virtual ancestors keep directory entries of the absolute package mount traversable. for ancestor in package .ancestors() .skip(1) .filter(|path| *path != Path::new("/")) { filesystem.mount( ancestor.to_str().unwrap(), Arc::new(sausage_vfs::Memory::default()), false, ); } filesystem.mount( package.to_str().unwrap(), Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?), false, ); let mut env: Vec<_> = std::env::vars_os() .filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?))) .filter(|(key, _)| { ["CARGO_", "RUST_", "SAUSAGE_"] .iter() .any(|prefix| key.starts_with(prefix)) }) .collect(); env.extend([ ("SAUSAGE_PID".into(), "test".into()), ("SAUSAGE_PLUGIN".into(), "test".into()), ("SAUSAGE_PARENT".into(), "core".into()), ]); let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS") .ok() .and_then(|value| value.parse::().ok()) .filter(|value| *value >= 1) .unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS); let deadline = tokio::time::Instant::now() .checked_add(std::time::Duration::from_secs(timeout_secs)) .ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?; let (ended, mut ended_rx) = tokio::sync::watch::channel(true); let mut guest = runtime .start_with_args( &component, InstanceConfig { filesystem, store_ended: ended, network: true, env, }, &args, ) .await?; let mut pending = std::collections::VecDeque::new(); let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1); let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel(); let stderr_task = tokio::spawn(async move { while let Some(line) = stderr.recv().await { let _ = errors.send(line); } }); let mut output = String::new(); let mut stdout_open = true; let mut stderr_open = true; let mut handlers = tokio::task::JoinSet::new(); let result = tokio::time::timeout_at(deadline, async { while stdout_open || stderr_open || !pending.is_empty() { tokio::select! { line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line { Some(line)=> { // Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line. if let Ok(request)=serde_json::from_str::(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() { let method = request["method"].as_str().unwrap(); if !matches!(method, "core.lock" | "core.log") { let params = request.get("params").cloned().unwrap_or(json!({})); let result = match method { "core.unlock" => host.call(method, params).await, "core.notify" => Ok(Value::Null), _ => Err(json!({"code":-32601,"message":"a test has no other plugins"})), }; if let Some(id) = request.get("id") { let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) }; if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); } } continue; } let host = host.clone(); handlers.spawn(async move { let method=request["method"].as_str().unwrap(); let params=request.get("params").cloned().unwrap_or(json!({})); let result=match method { "core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await, "core.notify"=>Ok(Value::Null), _=>Err(json!({"code":-32601,"message":"a test has no other plugins"})), }; request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})}) }); } else {output.push_str(line.trim_end_matches(['\r', '\n']));output.push('\n');} },None=>stdout_open=false, }, line=stderr_lines.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false}, response=handlers.join_next(), if !handlers.is_empty()=> { if let Some(response) = response.transpose()?.flatten() && guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); } }, } } Ok::<_, anyhow::Error>(guest.wait().await) }).await; handlers.abort_all(); // Dropping the instance aborts even a CPU-bound WASI loop. Wait for the // store and outstanding filesystem work before removing its test world. drop(guest); let _ = ended_rx.wait_for(|ended| *ended).await; let _ = stderr_task.await; tracker.idle().await; host.stop().await; let exit = result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??; Ok(Run { exit, output }) } /// Extracts libtest names from its stable list format. fn names(output: &str) -> Vec { output .lines() .filter_map(|line| line.strip_suffix(": test").map(str::to_owned)) .collect() } /// Lists tests, selects libtest filters, and runs each selected test on the core pool. pub async fn run(wasm: &Path, args: Vec) -> Result { let package = std::env::current_dir()?; let cache = std::env::var_os("XDG_CACHE_HOME") .map(PathBuf::from) .unwrap_or_else(|| { PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache") }) .join("sausage"); let compile_path = wasm.to_path_buf(); let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> { let runtime = Runtime::new(&cache)?; let component = runtime.compile(&std::fs::read(compile_path)?)?; Ok((runtime, component)) }) .await??; let mut listing_args = vec![wasm.display().to_string(), "--list".into()]; listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned()); let listing = instance( runtime.clone(), component.clone(), listing_args.clone(), package.clone(), ) .await?; if !matches!(listing.exit, Exit::Clean) { bail!( "cannot list WASI tests: {}\n{}", listing.exit, listing.output ); } if args.iter().any(|arg| arg == "--list") { print!("{}", listing.output); return Ok(true); } let selected = names(&listing.output); listing_args.push("--ignored".into()); let ignored = if args .iter() .any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored")) { Vec::new() } else { names( &instance( runtime.clone(), component.clone(), listing_args, package.clone(), ) .await? .output, ) }; println!("running {} tests", selected.len()); let cores = std::thread::available_parallelism() .map(usize::from) .unwrap_or(1); let semaphore = Arc::new(tokio::sync::Semaphore::new(cores)); let mut tasks = tokio::task::JoinSet::new(); let mut skipped = 0; for name in selected { if ignored.contains(&name) { println!("test {name} ... ignored"); skipped += 1; continue; } let runtime = runtime.clone(); let component = component.clone(); let package = package.clone(); let semaphore = semaphore.clone(); let arguments = vec![ wasm.display().to_string(), "--exact".into(), name.clone(), "--nocapture".into(), "--test-threads=1".into(), "--include-ignored".into(), ]; tasks.spawn(async move { let _permit = semaphore.acquire_owned().await?; let result = instance(runtime, component, arguments, package).await; Ok::<_, anyhow::Error>((name, result)) }); } let mut passed = 0; let mut failed = 0; while let Some(result) = tasks.join_next().await { let (name, result) = result??; let success = result .as_ref() .is_ok_and(|run| matches!(run.exit, Exit::Clean)); if success { passed += 1; } else { failed += 1; } println!("test {name} ... {}", if success { "ok" } else { "FAILED" }); match result { Ok(run) => { for line in run .output .lines() .filter(|line| { !line.trim().is_empty() && !line.starts_with("test result:") && !matches!(*line, "running 1 test" | "running 0 tests") && !matches!(*line, "ok" | "FAILED") }) .map(|line| { line.strip_prefix(&format!("test {name} ... ")) .unwrap_or(line) }) .filter(|line| { !matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty() }) { // Indentation keeps ordinary test output separate from our libtest status and totals. println!(" {line}"); } } Err(error) => println!("{error:#}"), } } println!( "\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out", if failed == 0 { "ok" } else { "FAILED" } ); Ok(failed == 0) }