294 lines
12 KiB
Rust
294 lines
12 KiB
Rust
//! 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<Runtime>,
|
|
component: Component,
|
|
args: Vec<String>,
|
|
package: PathBuf,
|
|
) -> Result<Run> {
|
|
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::<u64>().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::<Value>(&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<String> {
|
|
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<String>) -> Result<bool> {
|
|
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)
|
|
}
|