Files
sausage/cli/tests/cargo.rs
T
surma a090e1fd57 host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule
cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
2026-10-23 07:47:39 +01:00

645 lines
23 KiB
Rust

//! 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<Value, Value> {
let host = Host::start(&world.0).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)
.unwrap()
.register(&host);
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<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",
"--message-format=json",
"--bin",
"cargo-valid",
"--target",
"wasm32-wasip2",
"--target-dir",
])
.arg(world.0.join("state/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 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"));
assert!(manifest.contains("/plugins/cargo/sdk"));
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(&world.0).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);
assert!(
error["data"]["output"]
.as_str()
.unwrap()
.contains("plugins/missing/source")
);
host.stop().await;
}
/// 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(debug_assertions))] 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")),
)
.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());
}
/// 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() {
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 = 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::<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()
);
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 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;
}