//! Native build and WASI libtest integration checks under PROTOCOL.md section 11. 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) } /// 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 { let host = Host::start(&world.0).await.unwrap(); let result = host.call("cargo.build", json!({"plugin":name})).await; host.stop().await; result } 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 = 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 result = build(&world, "valid") .await .unwrap_or_else(|error| panic!("build failed: {error}")); let test = std::fs::read_dir(world.0.join("state/cargo/target/wasm32-wasip2/debug/deps")) .unwrap() .filter_map(|entry| { let path = entry.ok()?.path(); (path .extension() .is_some_and(|extension| extension == "wasm") && path.file_name()?.to_str()?.starts_with("cargo_valid-")) .then_some(path) }) .next() .unwrap(); Valid { world, result, test, } }) .await } /// Invokes the same runner subcommand that cargo uses. async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output { tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage")) .arg("test-runner") .arg(&valid.test) .args(args) .current_dir(valid.world.0.join("state/cargo/work")) .output() .await .unwrap() } /// 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"], 5); assert_eq!(valid.result["tests"]["failed"], 0); assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists()); } /// 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("/plugins/cargo/sdk") ); } /// 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")), ) .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")), ) .unwrap(); assert_eq!( builder .build(&world.0.join("plugins/simple/source")) .await .unwrap_err() .tests .failed, 1 ); } /// 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() { let world = TestWorld::new(); world.source("hang", &fixture("cargo-hang")); let host = Host::start(&world.0).await.unwrap(); 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::() { break pid; } tokio::task::yield_now().await; } }) .await .unwrap(); let pid = host .processes() .into_iter() .find(|(_, process)| process.plugin == "cargo") .unwrap() .0; host.call("core.kill", json!({"pid":pid})).await.unwrap(); assert!(task.await.unwrap().is_err()); tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { let status = std::fs::read_to_string(format!("/proc/{child}/stat")); if status .as_ref() .map_or(true, |status| status.split_whitespace().nth(2) == Some("Z")) { break; } tokio::task::yield_now().await; } }) .await .unwrap(); host.stop().await; }