1025 lines
38 KiB
Rust
1025 lines
38 KiB
Rust
//! Native build and WASI libtest integration checks under PROTOCOL.md section 11.
|
|
use super::common::CACHE;
|
|
use sausage::{Host, world::World};
|
|
use serde_json::{Value, json};
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::sync::OnceCell;
|
|
struct TestWorld(PathBuf);
|
|
impl TestWorld {
|
|
/// Creates a fresh native build world.
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("sausage-cargo-{}", uuid::Uuid::now_v7()));
|
|
World::create(&path).unwrap();
|
|
Self(path)
|
|
}
|
|
/// Short-deadline tests must not queue on another test's Cargo target lock.
|
|
async fn timeout_host(&self, name: &str) -> Host {
|
|
Host::start_with_cache(&self.0, &Path::new(CACHE).join("timeouts").join(name))
|
|
.await
|
|
.unwrap()
|
|
}
|
|
/// Creates a dependency-free plugin fixture.
|
|
fn simple(&self, name: &str, text: &str) {
|
|
let root = self.0.join(format!("plugins/{name}/source"));
|
|
std::fs::create_dir_all(root.join("src")).unwrap();
|
|
std::fs::write(
|
|
root.join("Cargo.toml"),
|
|
format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(root.join("src/main.rs"), text).unwrap();
|
|
}
|
|
/// Copies a fixture into its plugin source folder.
|
|
fn source(&self, name: &str, source: &Path) {
|
|
copy(source, &self.0.join(format!("plugins/{name}/source")));
|
|
}
|
|
}
|
|
impl Drop for TestWorld {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
/// Copies fixtures without their build outputs or git metadata.
|
|
fn copy(source: &Path, destination: &Path) {
|
|
std::fs::create_dir_all(destination).unwrap();
|
|
for entry in std::fs::read_dir(source).unwrap() {
|
|
let entry = entry.unwrap();
|
|
if matches!(entry.file_name().to_str(), Some("target" | ".git")) {
|
|
continue;
|
|
}
|
|
let destination = destination.join(entry.file_name());
|
|
if entry.file_type().unwrap().is_dir() {
|
|
copy(&entry.path(), &destination);
|
|
} else {
|
|
std::fs::copy(entry.path(), destination).unwrap();
|
|
}
|
|
}
|
|
}
|
|
/// Finds a checked-in build fixture.
|
|
fn fixture(name: &str) -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("tests/crates")
|
|
.join(name)
|
|
}
|
|
/// Builds through the real registered native plugin.
|
|
async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
|
|
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
|
.await
|
|
.unwrap();
|
|
let runner = world.0.join("state/runner with spaces");
|
|
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_sausage"), &runner).unwrap();
|
|
sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner, Path::new(CACHE))
|
|
.unwrap()
|
|
.register(&host);
|
|
let result = host.call("cargo.build", json!({"plugin":name})).await;
|
|
host.stop().await;
|
|
let logs = match &result {
|
|
Ok(output) => &output["logs"],
|
|
Err(error) => &error["data"]["logs"],
|
|
};
|
|
check_logs(world, logs);
|
|
result
|
|
}
|
|
/// RPC paths point at full raw streams, outside pruned build folders.
|
|
fn check_logs(world: &TestWorld, logs: &Value) {
|
|
let mut total = 0;
|
|
for stream in ["stdout", "stderr"] {
|
|
let path = logs[stream]["path"].as_str().expect("log path");
|
|
assert!(path.starts_with("/logs/cargo/"));
|
|
assert!(path.ends_with(&format!(".{stream}.log")));
|
|
let bytes = std::fs::metadata(world.0.join(path.trim_start_matches('/')))
|
|
.unwrap()
|
|
.len();
|
|
assert_eq!(logs[stream]["bytes"], bytes);
|
|
total += bytes;
|
|
}
|
|
assert_eq!(logs["total_bytes"], total);
|
|
}
|
|
struct Valid {
|
|
world: TestWorld,
|
|
result: Value,
|
|
test: PathBuf,
|
|
}
|
|
/// Shares one successful SDK build while runner invocations retain isolated worlds.
|
|
async fn valid() -> &'static Valid {
|
|
static VALID: OnceCell<Valid> = OnceCell::const_new();
|
|
VALID
|
|
.get_or_init(|| async {
|
|
let world = TestWorld::new();
|
|
world.source("valid", &fixture("cargo-valid"));
|
|
std::fs::create_dir_all(world.0.join("plugins/valid/source/target")).unwrap();
|
|
std::fs::write(
|
|
world.0.join("plugins/valid/source/target/excluded"),
|
|
"exclude",
|
|
)
|
|
.unwrap();
|
|
let nested = world.0.join("plugins/valid/source/src/target");
|
|
std::fs::create_dir_all(&nested).unwrap();
|
|
std::fs::write(nested.join("data"), "nested target").unwrap();
|
|
let git = world.0.join("plugins/valid/source/src/.git");
|
|
std::fs::create_dir_all(&git).unwrap();
|
|
std::fs::write(git.join("data"), "nested git").unwrap();
|
|
for number in 1..=20 {
|
|
std::fs::create_dir_all(world.0.join(format!("state/cargo/builds/{number}")))
|
|
.unwrap();
|
|
}
|
|
std::fs::create_dir_all(world.0.join("state/cargo/tmp/stale")).unwrap();
|
|
std::fs::write(world.0.join("state/cargo/tmp/stale/file"), "stale").unwrap();
|
|
let result = build(&world, "valid")
|
|
.await
|
|
.unwrap_or_else(|error| panic!("build failed: {error}"));
|
|
// Integration tests also build a normal binary. Cargo identifies the libtest artifact.
|
|
let artifacts = tokio::process::Command::new("cargo")
|
|
.args([
|
|
"test",
|
|
"--no-run",
|
|
"--release",
|
|
"--message-format=json",
|
|
"--bin",
|
|
"cargo-valid",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--target-dir",
|
|
])
|
|
.arg(Path::new(CACHE).join("cargo-target"))
|
|
.current_dir(world.0.join("state/cargo/work"))
|
|
.stdin(std::process::Stdio::null())
|
|
.output()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
artifacts.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&artifacts.stderr)
|
|
);
|
|
let mut tests: Vec<PathBuf> = String::from_utf8_lossy(&artifacts.stdout)
|
|
.lines()
|
|
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
|
.filter(|message| {
|
|
message["reason"] == "compiler-artifact"
|
|
&& message["profile"]["test"] == true
|
|
&& message["target"]["kind"]
|
|
.as_array()
|
|
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
|
|
})
|
|
.filter_map(|message| message["executable"].as_str().map(PathBuf::from))
|
|
.collect();
|
|
assert_eq!(
|
|
tests.len(),
|
|
1,
|
|
"expected exactly one libtest binary: {tests:?}"
|
|
);
|
|
let test = tests.pop().unwrap();
|
|
Valid {
|
|
world,
|
|
result,
|
|
test,
|
|
}
|
|
})
|
|
.await
|
|
}
|
|
/// Invokes the same runner subcommand that cargo uses.
|
|
async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output {
|
|
let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"));
|
|
command
|
|
.arg("test-runner")
|
|
.arg(&valid.test)
|
|
.args(args)
|
|
.current_dir(valid.world.0.join("state/cargo/work"))
|
|
.kill_on_drop(true);
|
|
tokio::time::timeout(std::time::Duration::from_secs(120), command.output())
|
|
.await
|
|
.expect("test runner exceeded 120 seconds")
|
|
.unwrap()
|
|
}
|
|
|
|
/// T23: unlocks stay ordered before the next lock over 300 acquisitions.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn unlock_order() {
|
|
assert!(
|
|
runner(valid().await, &["unlock_order"])
|
|
.await
|
|
.status
|
|
.success()
|
|
);
|
|
}
|
|
/// T23: each runner world is empty and has independent proc, self, and run mounts.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn empty_and_isolated_worlds() {
|
|
for name in ["empty_world", "isolation_", "environment", "late_response"] {
|
|
let output = runner(valid().await, &[name]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{name}: {} {}",
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
}
|
|
/// T23: successful builds preserve warnings and integration output without cargo progress noise.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn build_output_and_warnings() {
|
|
let valid = valid().await;
|
|
assert!(
|
|
valid.result["warnings"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("fixture build warning")
|
|
);
|
|
let logs = &valid.result["logs"];
|
|
let raw = |stream: &str| {
|
|
std::fs::read_to_string(
|
|
valid.world.0.join(
|
|
logs[stream]["path"]
|
|
.as_str()
|
|
.unwrap()
|
|
.trim_start_matches('/'),
|
|
),
|
|
)
|
|
.unwrap()
|
|
};
|
|
assert!(raw("stdout").contains("\"reason\":\"compiler-artifact\""));
|
|
assert!(raw("stderr").contains("Finished "));
|
|
let output = valid.result["tests"]["output"].as_str().unwrap();
|
|
assert!(output.contains("INTEGRATION_CASE"));
|
|
assert!(!output.contains("Compiling "));
|
|
assert!(!output.contains(" running 1 test"));
|
|
assert!(
|
|
!output
|
|
.lines()
|
|
.any(|line| line.starts_with(" ") && line.trim().is_empty())
|
|
);
|
|
}
|
|
/// T23: publication retains ten builds and the next build removes abandoned test worlds.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn build_retention_and_temporary_cleanup() {
|
|
let valid = valid().await;
|
|
assert_eq!(
|
|
std::fs::read_dir(valid.world.0.join("state/cargo/builds"))
|
|
.unwrap()
|
|
.count(),
|
|
10
|
|
);
|
|
assert!(!valid.world.0.join("state/cargo/tmp/stale").exists());
|
|
assert_eq!(
|
|
std::fs::read_dir(valid.world.0.join("state/cargo/tmp"))
|
|
.unwrap()
|
|
.count(),
|
|
0
|
|
);
|
|
}
|
|
/// T23: manifest preparation preserves workspaces, renamed target dependencies, and optional flags.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn manifest_and_nested_sources() {
|
|
let valid = valid().await;
|
|
let root = valid.world.0.join("state/cargo/work");
|
|
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
|
|
assert!(manifest.contains("workspace.package"));
|
|
assert!(manifest.contains("optional = true"));
|
|
let document: toml_edit::DocumentMut = manifest.parse().unwrap();
|
|
let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap());
|
|
assert_eq!(sdk.parent().unwrap(), Path::new(CACHE).join("sdk"));
|
|
let hash = sdk.file_name().unwrap().to_str().unwrap();
|
|
assert_eq!(hash.len(), 64);
|
|
assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
|
assert_eq!(
|
|
std::fs::read(sdk.join("src/lib.rs")).unwrap(),
|
|
std::fs::read(valid.world.0.join("plugins/cargo/sdk/src/lib.rs")).unwrap()
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(root.join("src/target/data")).unwrap(),
|
|
"nested target"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(root.join("src/.git/data")).unwrap(),
|
|
"nested git"
|
|
);
|
|
}
|
|
/// T23: discovery describes the native build method and preparation errors name their paths.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn native_discovery_and_error_paths() {
|
|
let world = TestWorld::new();
|
|
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
host.call("cargo.rpc.discover", json!({})).await.unwrap()["methods"][0]["name"],
|
|
"build"
|
|
);
|
|
let error = host
|
|
.call("cargo.build", json!({"plugin":"missing"}))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error["code"], -32004);
|
|
check_logs(&world, &error["data"]["logs"]);
|
|
assert!(
|
|
error["data"]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("plugins/missing/source")
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// Rework 3: an unavailable cache prevents builds, not host startup or SDK installation.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cache_failure_is_deferred_until_build() {
|
|
let world = TestWorld::new();
|
|
world.source("simple", &fixture("cargo-test-fail"));
|
|
// A file blocks directory creation even when the test runs as root.
|
|
let cache = world.0.join("blocked-cache");
|
|
std::fs::write(&cache, "not a directory").unwrap();
|
|
let host = Host::start_with_cache(&world.0, &cache).await.unwrap();
|
|
assert!(world.0.join("plugins/cargo/sdk/src/lib.rs").is_file());
|
|
let error = host
|
|
.call("cargo.build", json!({"plugin":"simple"}))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error["code"], -32004);
|
|
let output = error["data"]["output"].as_str().unwrap();
|
|
assert!(output.contains(cache.to_str().unwrap()), "{output}");
|
|
assert!(output.contains("create"), "{output}");
|
|
host.stop().await;
|
|
|
|
let standalone = TestWorld::new();
|
|
standalone.source("simple", &fixture("cargo-test-fail"));
|
|
let builder = sausage::cargo::Cargo::with_runner(
|
|
World::open(&standalone.0).unwrap(),
|
|
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
|
&cache,
|
|
)
|
|
.unwrap();
|
|
assert!(standalone.0.join("plugins/cargo/sdk/src/lib.rs").is_file());
|
|
let error = builder
|
|
.build(&standalone.0.join("plugins/simple/source"))
|
|
.await
|
|
.unwrap_err();
|
|
assert!(error.output.contains(cache.to_str().unwrap()), "{error}");
|
|
assert!(cache.is_file());
|
|
}
|
|
/// T23: release-only failures use data.output and retain successful WASI totals.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn release_only_failure() {
|
|
let world = TestWorld::new();
|
|
let root = world.0.join("plugins/release/source/src");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.parent().unwrap().join("Cargo.toml"),
|
|
"[package]\nname='release-only'\nversion='0.1.0'\nedition='2024'\n",
|
|
)
|
|
.unwrap();
|
|
std::fs::write(root.join("main.rs"), "#[cfg(not(test))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap();
|
|
let error = build(&world, "release").await.unwrap_err();
|
|
assert!(
|
|
error["data"]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("release-only error")
|
|
);
|
|
assert_eq!(error["data"]["tests"]["passed"], 1);
|
|
}
|
|
/// T23: cancellation retains serialization until a blocking source copy completes.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn cancelled_preparation_is_serialized() {
|
|
let world = TestWorld::new();
|
|
let first = world.0.join("first");
|
|
let second = world.0.join("second");
|
|
for (root, name) in [(&first, "first"), (&second, "second")] {
|
|
std::fs::create_dir_all(root.join("src")).unwrap();
|
|
std::fs::write(
|
|
root.join("Cargo.toml"),
|
|
format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"),
|
|
)
|
|
.unwrap();
|
|
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
|
}
|
|
for number in 0..10000 {
|
|
std::fs::write(first.join(format!("old-{number}")), "copy").unwrap();
|
|
}
|
|
let builder = std::sync::Arc::new(
|
|
sausage::cargo::Cargo::with_runner(
|
|
World::open(&world.0).unwrap(),
|
|
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
|
Path::new(CACHE),
|
|
)
|
|
.unwrap(),
|
|
);
|
|
let copying = builder.clone();
|
|
let task = tokio::spawn(async move { copying.build(&first).await });
|
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
|
loop {
|
|
if std::fs::read_dir(world.0.join("state/cargo/work"))
|
|
.is_ok_and(|entries| entries.count() > 10)
|
|
{
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
task.abort();
|
|
let _ = task.await;
|
|
builder.build(&second).await.unwrap();
|
|
assert!(
|
|
!std::fs::read_dir(world.0.join("state/cargo/work"))
|
|
.unwrap()
|
|
.any(|entry| entry
|
|
.unwrap()
|
|
.file_name()
|
|
.to_string_lossy()
|
|
.starts_with("old-"))
|
|
);
|
|
}
|
|
/// T23: multiple binary artifacts fail clearly instead of publishing an arbitrary binary.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn multiple_binaries() {
|
|
let world = TestWorld::new();
|
|
let root = world.0.join("plugins/multi/source/src/bin");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.parent().unwrap().parent().unwrap().join("Cargo.toml"),
|
|
"[package]\nname='multiple'\nversion='0.1.0'\nedition='2024'\n",
|
|
)
|
|
.unwrap();
|
|
for name in ["one", "two"] {
|
|
std::fs::write(root.join(format!("{name}.rs")), "fn main() {}\n").unwrap();
|
|
}
|
|
let error = build(&world, "multi").await.unwrap_err();
|
|
assert!(
|
|
error["data"]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("exactly one binary")
|
|
);
|
|
}
|
|
/// PROTOCOL 11: a valid SDK plugin builds and publishes a release component without activation.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn valid_build() {
|
|
let valid = valid().await;
|
|
let wasm = valid.result["wasm"].as_str().unwrap();
|
|
assert!(wasm.starts_with("/state/cargo/builds/"));
|
|
assert!(valid.world.0.join(wasm.trim_start_matches('/')).is_file());
|
|
assert_eq!(valid.result["tests"]["passed"], 12);
|
|
assert_eq!(valid.result["tests"]["failed"], 0);
|
|
assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists());
|
|
assert!(!valid.world.0.join("state/cargo/target").exists());
|
|
assert!(
|
|
Path::new(CACHE)
|
|
.join("cargo-target/wasm32-wasip2/release")
|
|
.is_dir()
|
|
);
|
|
}
|
|
/// PROTOCOL 11: tests and compilation share one source copy without target metadata.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn source_copy() {
|
|
let valid = valid().await;
|
|
assert!(
|
|
!valid
|
|
.world
|
|
.0
|
|
.join("state/cargo/work/target/excluded")
|
|
.exists()
|
|
);
|
|
assert!(
|
|
std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml"))
|
|
.unwrap()
|
|
.contains(Path::new(CACHE).join("sdk").to_str().unwrap())
|
|
);
|
|
}
|
|
/// PROTOCOL 11: compiler failures return compiler diagnostics.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn compile_error() {
|
|
let world = TestWorld::new();
|
|
world.source("bad", &fixture("cargo-fail"));
|
|
let error = build(&world, "bad").await.unwrap_err();
|
|
assert!(
|
|
error["data"]["tests"]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("error")
|
|
);
|
|
}
|
|
/// PROTOCOL 11: a failing test rejects the build with its test output.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn failing_test() {
|
|
let world = TestWorld::new();
|
|
world.source("bad", &fixture("cargo-test-fail"));
|
|
let error = build(&world, "bad").await.unwrap_err();
|
|
assert_eq!(error["data"]["tests"]["failed"], 1);
|
|
assert!(
|
|
error["data"]["tests"]["output"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("deliberate WASI panic")
|
|
);
|
|
}
|
|
/// PROTOCOL 11: one panic does not prevent another test instance from passing.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn panic_is_isolated() {
|
|
let world = TestWorld::new();
|
|
world.source("bad", &fixture("cargo-test-fail"));
|
|
let error = build(&world, "bad").await.unwrap_err();
|
|
assert_eq!(error["data"]["tests"]["passed"], 1);
|
|
}
|
|
/// PROTOCOL 11: build scripts can compile and link WASI C code.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn c_build() {
|
|
let world = TestWorld::new();
|
|
world.source("c", &fixture("cargo-c"));
|
|
let result = build(&world, "c")
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{error}"));
|
|
assert_eq!(result["tests"]["passed"], 1);
|
|
}
|
|
/// PROTOCOL 11: libtest name filters select only matching tests.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn name_filter() {
|
|
let output = runner(valid().await, &["arithmetic"]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"));
|
|
}
|
|
/// PROTOCOL 11: --ignored selects ignored tests instead of normal tests.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn ignored_filter() {
|
|
let output = runner(valid().await, &["--ignored"]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(String::from_utf8_lossy(&output.stdout).contains("test tests::ignored_case ... ok"));
|
|
}
|
|
/// PROTOCOL 11: CARGO_MANIFEST_DIR fixture reads work at the package's absolute host path.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn manifest_fixture() {
|
|
let output = runner(valid().await, &["fixture_at_manifest_path"]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
/// PROTOCOL 11: WASI Preview 2 keeps cwd at / despite PWD. Absolute manifest paths reach fixtures.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn current_folder() {
|
|
let valid = valid().await;
|
|
let output = runner(valid, &["current_directory"]).await;
|
|
let text = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert!(
|
|
text.lines()
|
|
.any(|line| line.ends_with("WASI_CURRENT_DIR=/")),
|
|
"{text}"
|
|
);
|
|
}
|
|
/// PROTOCOL 11: the SDK can acquire and release locks through the runner's core transport.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn sdk_lock() {
|
|
let output = runner(valid().await, &["sdk_lock"]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
/// PROTOCOL 11: calls to other plugins fail with a clear runner error.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn unavailable_plugin() {
|
|
let output = runner(valid().await, &["other_plugin_fails"]).await;
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
/// PROTOCOL 11: the notes example tests run in isolated worlds under the same runner.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn notes_example() {
|
|
let world = TestWorld::new();
|
|
world.source(
|
|
"notes",
|
|
&Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.join("examples/notes"),
|
|
);
|
|
let result = build(&world, "notes")
|
|
.await
|
|
.unwrap_or_else(|error| panic!("{error}"));
|
|
assert!(result["tests"]["passed"].as_u64().unwrap() > 0);
|
|
}
|
|
/// PROTOCOL 11: SDK installation leaves unchanged files and manifests untouched.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn unchanged_sdk() {
|
|
let world = TestWorld::new();
|
|
let setup = || {
|
|
sausage::cargo::Cargo::with_runner(
|
|
World::open(&world.0).unwrap(),
|
|
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
|
Path::new(CACHE),
|
|
)
|
|
.unwrap()
|
|
};
|
|
setup();
|
|
let manifest = world.0.join("plugins/cargo/sdk/Cargo.toml");
|
|
let modified = std::fs::metadata(&manifest).unwrap().modified().unwrap();
|
|
setup();
|
|
assert_eq!(
|
|
std::fs::metadata(manifest).unwrap().modified().unwrap(),
|
|
modified
|
|
);
|
|
}
|
|
/// PROTOCOL 11: init can build a plugin without a running host.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn standalone_builder() {
|
|
let world = TestWorld::new();
|
|
world.source("simple", &fixture("cargo-test-fail"));
|
|
let builder = sausage::cargo::Cargo::with_runner(
|
|
World::open(&world.0).unwrap(),
|
|
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
|
Path::new(CACHE),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
builder
|
|
.build(&world.0.join("plugins/simple/source"))
|
|
.await
|
|
.unwrap_err()
|
|
.tests
|
|
.failed,
|
|
1
|
|
);
|
|
}
|
|
/// SAU-126: a named CPU-bound WASI test times out, while other instances still run.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn named_test_timeout_and_mutex_release() {
|
|
let world = TestWorld::new();
|
|
world.simple("test-timeout", "fn main() {} #[test] fn a_hung() { loop { std::hint::black_box(1); } } #[test] fn z_remaining() { eprintln!(\"REMAINING_TEST_RAN\"); }");
|
|
world.simple("next", "fn main() {} #[test] fn passes() {}");
|
|
let integration = world.0.join("plugins/test-timeout/source/tests");
|
|
std::fs::create_dir_all(&integration).unwrap();
|
|
std::fs::write(
|
|
integration.join("remaining.rs"),
|
|
"#[test] fn integration_remaining() { eprintln!(\"REMAINING_BINARY_RAN\"); }",
|
|
)
|
|
.unwrap();
|
|
let host = world.timeout_host("named").await;
|
|
let error = tokio::time::timeout(
|
|
std::time::Duration::from_secs(60),
|
|
host.call(
|
|
"cargo.build",
|
|
json!({"plugin":"test-timeout", "test_timeout_secs":1, "timeout_secs":45}),
|
|
),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap_err();
|
|
assert_eq!(error["code"], -32004);
|
|
assert_eq!(error["message"], "WASI tests failed");
|
|
assert_eq!(error["data"]["tests"]["failed"], 1);
|
|
assert_eq!(error["data"]["tests"]["passed"], 2);
|
|
let output = error["data"]["output"].as_str().unwrap();
|
|
assert!(output.contains("test a_hung ... FAILED"), "{output}");
|
|
assert!(output.contains("timed out after 1 s"), "{output}");
|
|
assert!(output.contains("REMAINING_TEST_RAN"), "{output}");
|
|
assert!(output.contains("REMAINING_BINARY_RAN"), "{output}");
|
|
let next = tokio::time::timeout(
|
|
std::time::Duration::from_secs(30),
|
|
host.call("cargo.build", json!({"plugin":"next"})),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(next["tests"]["passed"], 1);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// Wait for a build-script child without using timing sleeps.
|
|
async fn script_child(world: &TestWorld) -> u32 {
|
|
tokio::time::timeout(std::time::Duration::from_secs(60), async {
|
|
loop {
|
|
if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid"))
|
|
&& let Ok(pid) = text.trim().parse()
|
|
{
|
|
break pid;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("build script never started")
|
|
}
|
|
/// A killed descendant may briefly remain as a zombie, but cannot keep executing.
|
|
async fn child_ended(child: u32) {
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
|
loop {
|
|
match std::fs::read_to_string(format!("/proc/{child}/stat")) {
|
|
Ok(status) if status.split_whitespace().nth(2) == Some("Z") => break,
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => break,
|
|
_ => tokio::task::yield_now().await,
|
|
}
|
|
}
|
|
})
|
|
.await
|
|
.expect("cargo descendant survived cancellation/timeout");
|
|
}
|
|
|
|
/// SAU-126: an explicit small total budget kills build.rs and unblocks a queued build.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn build_timeout_and_queued_build() {
|
|
let world = TestWorld::new();
|
|
world.source("hang", &fixture("cargo-hang"));
|
|
world.simple("next", "fn main() {} #[test] fn passes() {}");
|
|
let host = world.timeout_host("queued").await;
|
|
let building = host.clone();
|
|
let task = tokio::spawn(async move {
|
|
building
|
|
.call("cargo.build", json!({"plugin":"hang", "timeout_secs":5}))
|
|
.await
|
|
});
|
|
let child = script_child(&world).await;
|
|
let queued_host = host.clone();
|
|
let queued = tokio::spawn(async move {
|
|
queued_host
|
|
.call("cargo.build", json!({"plugin":"next", "timeout_secs":3}))
|
|
.await
|
|
});
|
|
let error = task.await.unwrap().unwrap_err();
|
|
assert_eq!(error["code"], -32004);
|
|
assert_eq!(error["message"], "timeout");
|
|
assert_eq!(error["data"]["reason"], "timeout");
|
|
check_logs(&world, &error["data"]["logs"]);
|
|
assert_eq!(error["data"]["step"], "tests");
|
|
assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0);
|
|
assert!(!error["data"]["output"].as_str().unwrap().is_empty());
|
|
child_ended(child).await;
|
|
let next = tokio::time::timeout(std::time::Duration::from_secs(20), queued)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(next["tests"]["passed"], 1);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// SAU-126: the same total deadline covers release compilation and retains test results.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn release_build_timeout_retains_tests() {
|
|
let world = TestWorld::new();
|
|
world.simple(
|
|
"release-timeout",
|
|
r#"fn main() {}
|
|
#[test] fn prepare_release() {
|
|
std::fs::write(concat!(env!("CARGO_MANIFEST_DIR"), "/release-marker"), "ready").unwrap();
|
|
}"#,
|
|
);
|
|
std::fs::write(
|
|
world.0.join("plugins/release-timeout/source/build.rs"),
|
|
r#"
|
|
fn main() {
|
|
println!("cargo:rerun-if-changed=release-marker");
|
|
if std::path::Path::new("release-marker").exists() {
|
|
std::process::Command::new("sh")
|
|
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
|
|
.status().unwrap();
|
|
}
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
let host = world.timeout_host("release").await;
|
|
let building = host.clone();
|
|
let task = tokio::spawn(async move {
|
|
building
|
|
.call(
|
|
"cargo.build",
|
|
json!({"plugin":"release-timeout", "timeout_secs":5}),
|
|
)
|
|
.await
|
|
});
|
|
let child = script_child(&world).await;
|
|
let error = tokio::time::timeout(std::time::Duration::from_secs(10), task)
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
.unwrap_err();
|
|
assert_eq!(error["code"], -32004);
|
|
assert_eq!(error["data"]["reason"], "timeout");
|
|
check_logs(&world, &error["data"]["logs"]);
|
|
assert_eq!(error["data"]["step"], "release build");
|
|
assert_eq!(error["data"]["tests"]["passed"], 1);
|
|
assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0);
|
|
child_ended(child).await;
|
|
host.stop().await;
|
|
}
|
|
|
|
/// SAU-126: omission selects the real 900-second deadline, without a 15-minute test.
|
|
#[tokio::test]
|
|
async fn omitted_build_timeout_uses_default() {
|
|
let world = TestWorld::new();
|
|
world.source("hang", &fixture("cargo-hang"));
|
|
let host = world.timeout_host("default").await;
|
|
let building = host.clone();
|
|
let task =
|
|
tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await });
|
|
let child = script_child(&world).await;
|
|
tokio::time::pause();
|
|
tokio::time::advance(std::time::Duration::from_secs(
|
|
sausage::cargo::DEFAULT_TIMEOUT_SECS,
|
|
))
|
|
.await;
|
|
// Let the awakened deadline observe the advanced clock before resuming it.
|
|
tokio::task::yield_now().await;
|
|
let error = task.await.unwrap().unwrap_err();
|
|
tokio::time::resume();
|
|
assert_eq!(error["data"]["reason"], "timeout");
|
|
assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 900.0);
|
|
child_ended(child).await;
|
|
host.stop().await;
|
|
}
|
|
|
|
/// SAU-126: a caller ending stops its attached native build and releases the mutex.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn ended_caller_releases_build_mutex() {
|
|
let world = TestWorld::new();
|
|
world.source("hang", &fixture("cargo-hang"));
|
|
world.simple("next", "fn main() {} #[test] fn passes() {}");
|
|
let host = world.timeout_host("caller").await;
|
|
let (finish, finished) = tokio::sync::watch::channel(false);
|
|
host.register_native(
|
|
"caller",
|
|
std::sync::Arc::new(move |context, _, _| {
|
|
let mut finished = finished.clone();
|
|
Box::pin(async move {
|
|
tokio::spawn(
|
|
async move { context.call("cargo.build", json!({"plugin":"hang"})).await },
|
|
);
|
|
let _ = finished.wait_for(|done| *done).await;
|
|
Ok(json!({}))
|
|
})
|
|
}),
|
|
);
|
|
let calling = host.clone();
|
|
let caller = tokio::spawn(async move { calling.call("caller.run", json!({})).await });
|
|
let child = script_child(&world).await;
|
|
let queued_host = host.clone();
|
|
let queued = tokio::spawn(async move {
|
|
queued_host
|
|
.call("cargo.build", json!({"plugin":"next"}))
|
|
.await
|
|
});
|
|
finish.send_replace(true);
|
|
caller.await.unwrap().unwrap();
|
|
let next = tokio::time::timeout(std::time::Duration::from_secs(12), queued)
|
|
.await
|
|
.expect("ended caller retained mutex")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(next["tests"]["passed"], 1);
|
|
child_ended(child).await;
|
|
host.stop().await;
|
|
}
|
|
|
|
/// SAU-126: discovery exposes non-null defaults; invalid budgets cannot disable limits.
|
|
#[tokio::test]
|
|
async fn timeout_params_discovery_and_validation() {
|
|
let world = TestWorld::new();
|
|
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
|
.await
|
|
.unwrap();
|
|
let doc = host.call("cargo.rpc.discover", json!({})).await.unwrap();
|
|
let params = doc["methods"][0]["params"].as_array().unwrap();
|
|
for (name, default) in [("timeout_secs", 900), ("test_timeout_secs", 120)] {
|
|
let param = params.iter().find(|param| param["name"] == name).unwrap();
|
|
assert_eq!(param["schema"]["default"], default);
|
|
assert_eq!(param["schema"]["minimum"], 1);
|
|
assert!(!param["description"].as_str().unwrap().is_empty());
|
|
for value in [json!(0), json!(-1), json!(1.5), json!("1"), json!(null)] {
|
|
let mut args = json!({"plugin":"missing"});
|
|
args[name] = value;
|
|
let error = host.call("cargo.build", args).await.unwrap_err();
|
|
assert_eq!(error["code"], -32602, "{error}");
|
|
}
|
|
}
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 11: killing a native build also kills its build-script descendants.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn kill_process_group() {
|
|
assert!(
|
|
Path::new("/proc/self/stat").is_file(),
|
|
"process-group checks require /proc"
|
|
);
|
|
let world = TestWorld::new();
|
|
world.source("hang", &fixture("cargo-hang"));
|
|
let host = world.timeout_host("kill").await;
|
|
let building = host.clone();
|
|
let task =
|
|
tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await });
|
|
let child = tokio::time::timeout(std::time::Duration::from_secs(120), async {
|
|
loop {
|
|
if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid"))
|
|
&& let Ok(pid) = text.trim().parse::<u32>()
|
|
{
|
|
break pid;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let mut ancestor = child;
|
|
loop {
|
|
let command = std::fs::read(format!("/proc/{ancestor}/cmdline")).unwrap();
|
|
let arguments: Vec<_> = command
|
|
.split(|byte| *byte == 0)
|
|
.map(|part| String::from_utf8_lossy(part).to_string())
|
|
.collect();
|
|
if arguments[0].ends_with("/cargo") || arguments[0] == "cargo" {
|
|
assert!(!arguments.iter().any(|arg| arg == "--offline"));
|
|
assert!(
|
|
arguments
|
|
.iter()
|
|
.any(|arg| arg.starts_with("target.wasm32-wasip2.runner=["))
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_link(format!("/proc/{ancestor}/fd/0")).unwrap(),
|
|
PathBuf::from("/dev/null")
|
|
);
|
|
break;
|
|
}
|
|
let status = std::fs::read_to_string(format!("/proc/{ancestor}/status")).unwrap();
|
|
ancestor = status
|
|
.lines()
|
|
.find_map(|line| line.strip_prefix("PPid:"))
|
|
.unwrap()
|
|
.trim()
|
|
.parse()
|
|
.unwrap();
|
|
assert!(ancestor > 1, "cargo ancestor not found");
|
|
}
|
|
let pid = host
|
|
.processes()
|
|
.into_iter()
|
|
.find(|(_, process)| process.plugin == "cargo")
|
|
.unwrap()
|
|
.0;
|
|
let fs = host.filesystem().await.unwrap();
|
|
use sausage_vfs::Layer;
|
|
assert!(
|
|
fs.stat(&format!("/proc/{pid}/info.json"), true)
|
|
.await
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
!host
|
|
.call("core.log", json!({"pid":pid,"grep":"start build"}))
|
|
.await
|
|
.unwrap()
|
|
.as_array()
|
|
.unwrap()
|
|
.is_empty()
|
|
);
|
|
// Queue a build on the SAME builder before cancelling the lock holder.
|
|
world.simple("next", "fn main() {} #[test] fn passes() {}");
|
|
let queued_host = host.clone();
|
|
let queued = tokio::spawn(async move {
|
|
queued_host
|
|
.call("cargo.build", json!({"plugin":"next"}))
|
|
.await
|
|
});
|
|
host.call("core.kill", json!({"pid":pid})).await.unwrap();
|
|
assert!(task.await.unwrap().is_err());
|
|
child_ended(ancestor).await;
|
|
let next = tokio::time::timeout(std::time::Duration::from_secs(10), queued)
|
|
.await
|
|
.expect("cancelled build retained mutex")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(next["tests"]["passed"], 1);
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
|
loop {
|
|
let status = std::fs::read_to_string(format!("/proc/{child}/stat"));
|
|
if match status {
|
|
Ok(status) => status.split_whitespace().nth(2) == Some("Z"),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
|
Err(error) => panic!("read /proc/{child}/stat: {error}"),
|
|
} {
|
|
break;
|
|
}
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
host.stop().await;
|
|
}
|