From 22590c608daf59420b2e455ac89a46059133bb8f Mon Sep 17 00:00:00 2001 From: agent-pool-12 Date: Sat, 17 Oct 2026 15:50:09 +0000 Subject: [PATCH] cargo: run host unit tests before builds --- PROTOCOL.md | 16 +- cli/src/cargo_actor.rs | 214 +++++++++++++++---- cli/src/main.rs | 2 +- cli/tests/crates/cargo-test-fail/Cargo.toml | 6 + cli/tests/crates/cargo-test-fail/src/main.rs | 9 + cli/tests/crates/cargo-valid/src/main.rs | 8 + cli/tests/host.rs | 49 ++++- 7 files changed, 242 insertions(+), 62 deletions(-) create mode 100644 cli/tests/crates/cargo-test-fail/Cargo.toml create mode 100644 cli/tests/crates/cargo-test-fail/src/main.rs diff --git a/PROTOCOL.md b/PROTOCOL.md index ddf9cb7..4410e95 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -231,9 +231,19 @@ folder again, as at start. {"type": "build", "source": "/actor/agent/public/drafts/notes"} ``` -The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", "built_by": "cargo", ...}` -or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and -`built_by` on to `spawn` or `upgrade`. +The build request may include `"skip_tests": true` to skip Rust unit tests. +By default, `cargo` first runs `cargo test` for the host target (not WASI), +so pure-logic unit tests in a `wasm32-wasip2` actor crate can run without a +WASI test runner. If tests fail or cannot compile, the build fails before the +WASI build. The reply carries `tests: {"passed": , "failed": , +"output": ""}`; counts sum Cargo's test-result +summaries, and a test command/compilation failure without a summary counts as +one failure. With `skip_tests: true`, counts are zero and output says tests +were skipped. + +The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", "built_by": "cargo", "tests": {...}, ...}` +or `{"type": "build_failed", "errors": "...", "tests": {...}}`. Pass +`wasm`, `source`, and `built_by` on to `spawn` or `upgrade`. A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points that dependency at the SDK of the host, which it publishes at diff --git a/cli/src/cargo_actor.rs b/cli/src/cargo_actor.rs index 9976c17..381aed0 100644 --- a/cli/src/cargo_actor.rs +++ b/cli/src/cargo_actor.rs @@ -1,8 +1,9 @@ //! The native `cargo` actor: builds Rust source folders into `.wasm` files. //! //! Request: `{"type": "build", "source": "/actor//public/"}` -//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", ...}` -//! `{"type": "build_failed", "errors": "..."}` +//! optional `"skip_tests": true` skips native `cargo test` (tests run by default). +//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds//.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}` +//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}` //! //! The broker knows nothing about Rust. This actor is one way to get a //! `.wasm` file. Other actors can add other languages. @@ -32,13 +33,45 @@ pub const TARGET: &str = "wasm32-wasip2"; /// The source of the `sausage-actor` SDK. static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk"); +#[derive(Clone, Default, Debug)] +pub struct TestResults { + pub passed: u64, + pub failed: u64, + pub output: String, +} + +impl TestResults { + fn value(&self) -> Value { + json!({"passed": self.passed, "failed": self.failed, "output": self.output}) + } +} + pub struct BuildOutput { /// Host path of the published `.wasm` file. pub wasm: PathBuf, pub seconds: f64, pub warnings: String, + pub tests: TestResults, } +#[derive(Debug)] +pub struct BuildFailure { + pub reason: String, + pub tests: TestResults, +} + +impl std::fmt::Display for BuildFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.tests.failed > 0 && !self.tests.output.is_empty() { + write!(f, "{}:\n{}", self.reason, self.tests.output) + } else { + f.write_str(&self.reason) + } + } +} + +impl std::error::Error for BuildFailure {} + /// All builds share one target folder, so they run one at a time. pub struct Cargo { world: World, @@ -51,51 +84,84 @@ impl Cargo { Ok(Self { world }) } - /// Build the crate in `source` (a host path) and publish the `.wasm` file - /// under `/actor/cargo/public/builds//`. - pub async fn build(&self, source: &Path) -> Result { + /// Run native `cargo test` unless opted out, then build and publish the + /// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds//`. + pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result { if !source.join("Cargo.toml").is_file() { - bail!("{} has no Cargo.toml", source.display()); + return Err(BuildFailure { + reason: format!("{} has no Cargo.toml", source.display()), + tests: TestResults::default(), + }); } let started = Instant::now(); let data = self.world.data_dir(ID); let build_dir = data.join("work"); world::replace_source(source, &build_dir)?; let manifest = build_dir.join("Cargo.toml"); - std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?; + let original = std::fs::read_to_string(&manifest).context("read build manifest")?; + let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?; + std::fs::write(&manifest, prepared).context("write prepared manifest")?; - let mut cmd = Command::new("cargo"); - cmd.arg("build") - .arg("--release") - .arg("--target") - .arg(TARGET) - .arg("--message-format=json-render-diagnostics") - .arg("--target-dir") - .arg(data.join("target")) - .current_dir(&build_dir) - .kill_on_drop(true); - let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; - let stderr = String::from_utf8_lossy(&out.stderr).to_string(); - if !out.status.success() { - bail!("{}", strip_progress(&stderr)); - } - let mut artifact = None; - for line in String::from_utf8_lossy(&out.stdout).lines() { - if let Ok(v) = serde_json::from_str::(line) - && v["reason"] == "compiler-artifact" - && let Some(exe) = v["executable"].as_str() - { - artifact = Some(PathBuf::from(exe)); + let target_dir = data.join("target"); + let tests = if skip_tests { + TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() } + } else { + match run_tests(&build_dir, &target_dir).await { + Ok(tests) => tests, + Err(error) => { + let output = format!("cannot run cargo tests: {error:#}"); + return Err(BuildFailure { + reason: output.clone(), + tests: TestResults { failed: 1, output, ..TestResults::default() }, + }); + } } + }; + if tests.failed > 0 { + return Err(BuildFailure { reason: "unit tests failed".into(), tests }); } - let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?; - let n = next_build_number(&self.world.public_dir(ID).join("builds"))?; - let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string()); - let name = artifact.file_name().context("artifact has no file name")?; - let dest = dest_dir.join(name); - world::write_atomic(&dest, &std::fs::read(&artifact)?)?; - Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) }) + let build_result: Result = async { + let mut cmd = Command::new("cargo"); + cmd.arg("build") + .arg("--release") + .arg("--target") + .arg(TARGET) + .arg("--message-format=json-render-diagnostics") + .arg("--target-dir") + .arg(&target_dir) + .current_dir(&build_dir) + .kill_on_drop(true); + let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + if !out.status.success() { + bail!("{}", strip_progress(&stderr)); + } + let mut artifact = None; + for line in String::from_utf8_lossy(&out.stdout).lines() { + if let Ok(v) = serde_json::from_str::(line) + && v["reason"] == "compiler-artifact" + && let Some(exe) = v["executable"].as_str() + { + artifact = Some(PathBuf::from(exe)); + } + } + let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?; + + let n = next_build_number(&self.world.public_dir(ID).join("builds"))?; + let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string()); + let name = artifact.file_name().context("artifact has no file name")?; + let dest = dest_dir.join(name); + world::write_atomic(&dest, &std::fs::read(&artifact)?)?; + Ok(BuildOutput { + wasm: dest, + seconds: started.elapsed().as_secs_f64(), + warnings: strip_progress(&stderr), + tests: tests.clone(), + }) + } + .await; + build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests }) } /// The native `cargo` actor. Builds run one at a time, in order. @@ -106,7 +172,7 @@ impl Cargo { continue; } let body = self.handle(&broker, &env).await.unwrap_or_else(|e| { - json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")}) + json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()}) }); broker.send(ID, Some(&env.from), None, Some(&env.id), body); } @@ -117,22 +183,76 @@ impl Cargo { Some("build") => { let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?; let source = broker.world.resolve(&env.from, source_s)?; - let out = self.build(&source).await?; - let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); - Ok(json!({ - "type": "built", - "wasm": wasm, - "source": source_s, - "built_by": ID, - "seconds": (out.seconds * 10.0).round() / 10.0, - "warnings": out.warnings, - })) + let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false); + match self.build(&source, skip_tests).await { + Ok(out) => { + let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); + Ok(json!({ + "type": "built", + "wasm": wasm, + "source": source_s, + "built_by": ID, + "seconds": (out.seconds * 10.0).round() / 10.0, + "warnings": out.warnings, + "tests": out.tests.value(), + })) + } + Err(failure) => Ok(json!({ + "type": "build_failed", + "source": source_s, + "errors": failure.reason, + "tests": failure.tests.value(), + })), + } } other => bail!("unknown request {other:?}. Known: build"), } } } +/// Run tests for the host triple: WASI crates' pure-logic tests can use the +/// normal Rust test harness without needing a WASI test runner. +async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result { + let mut cmd = Command::new("cargo"); + cmd.arg("test") + .arg("--target-dir") + .arg(target_dir) + .current_dir(build_dir) + .kill_on_drop(true); + let out = cmd.output().await.context("cannot run cargo test")?; + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + let combined = match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => String::new(), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}\n{stderr}"), + }; + let output = strip_progress(&combined); + let passed = test_count(&output, "passed"); + let mut failed = test_count(&output, "failed"); + // Compilation failures and runner errors have no `test result` summary, + // but still make the default test phase fail. + if !out.status.success() && failed == 0 { + failed = 1; + } + Ok(TestResults { passed, failed, output }) +} + +/// Count the `passed` or `failed` totals in cargo's per-target test summaries. +fn test_count(output: &str, field: &str) -> u64 { + output + .lines() + .filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest)) + .flat_map(|summary| summary.split(';')) + .filter_map(|part| { + let words: Vec<_> = part.split_whitespace().collect(); + let index = words.iter().position(|word| *word == field)?; + words.get(index.checked_sub(1)?)?.parse().ok() + }) + .sum() +} + /// Point a `sausage-actor` dependency at `sdk` (keeping its features), and /// add an empty `[workspace]`, so the crate never joins a parent workspace. fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result { diff --git a/cli/src/main.rs b/cli/src/main.rs index 48e8cbd..77f3ee3 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -85,7 +85,7 @@ async fn init(dir: PathBuf) -> Result<()> { std::fs::create_dir_all(&source)?; AGENT_SOURCE.extract(&source).context("extract agent source")?; println!("Building the agent actor (the first build takes a few minutes) …"); - let out = cargo_actor::Cargo::new(world.clone())?.build(&source).await.context("build agent")?; + let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?; std::fs::copy(&out.wasm, world.wasm_path("agent"))?; println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display()); Ok(()) diff --git a/cli/tests/crates/cargo-test-fail/Cargo.toml b/cli/tests/crates/cargo-test-fail/Cargo.toml new file mode 100644 index 0000000..a0be58d --- /dev/null +++ b/cli/tests/crates/cargo-test-fail/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "cargo-test-fail" +version = "0.1.0" +edition = "2024" + +[workspace] diff --git a/cli/tests/crates/cargo-test-fail/src/main.rs b/cli/tests/crates/cargo-test-fail/src/main.rs new file mode 100644 index 0000000..65dfa17 --- /dev/null +++ b/cli/tests/crates/cargo-test-fail/src/main.rs @@ -0,0 +1,9 @@ +fn main() {} + +#[cfg(test)] +mod tests { + #[test] + fn deliberate_unit_test_failure() { + assert_eq!(2 + 2, 5); + } +} diff --git a/cli/tests/crates/cargo-valid/src/main.rs b/cli/tests/crates/cargo-valid/src/main.rs index f328e4d..0e259da 100644 --- a/cli/tests/crates/cargo-valid/src/main.rs +++ b/cli/tests/crates/cargo-valid/src/main.rs @@ -1 +1,9 @@ fn main() {} + +#[cfg(test)] +mod tests { + #[test] + fn unit_tests_run_on_the_host_target() { + assert_eq!(2 + 2, 4); + } +} diff --git a/cli/tests/host.rs b/cli/tests/host.rs index 605b5be..005b3c0 100644 --- a/cli/tests/host.rs +++ b/cli/tests/host.rs @@ -619,9 +619,10 @@ async fn unknown_broker_request_lists_the_known_ones() { } #[tokio::test] -async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() { +async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() { let world = create_world("cargo-actor"); let (host, mut mailbox) = start(&world).await; + let valid_source = cargo_source("cargo-valid"); let valid_id = host.broker.send( "console", @@ -630,12 +631,42 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() { None, json!({"type": "build", "source": valid_source.display().to_string()}), ); - let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await; - assert_eq!(built.body["type"], "built"); + let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(180)).await; + assert_eq!(built.body["type"], "built", "{built:?}"); + assert_eq!(built.body["tests"]["passed"], 1); + assert_eq!(built.body["tests"]["failed"], 0); + assert!(built.body["tests"]["output"].as_str().unwrap().contains("unit_tests_run_on_the_host_target")); let wasm = built.body["wasm"].as_str().unwrap(); assert!(wasm.ends_with(".wasm")); assert!(host.world.resolve("console", wasm).unwrap().is_file()); + let failing_test_source = cargo_source("cargo-test-fail"); + let failed_test_id = host.broker.send( + "console", + Some("cargo"), + None, + None, + json!({"type": "build", "source": failing_test_source.display().to_string()}), + ); + let failed_test = receive_reply(&mut mailbox, &failed_test_id, Duration::from_secs(120)).await; + assert_eq!(failed_test.body["type"], "build_failed", "{failed_test:?}"); + assert_eq!(failed_test.body["tests"]["passed"], 0); + assert_eq!(failed_test.body["tests"]["failed"], 1); + assert!(failed_test.body["tests"]["output"].as_str().unwrap().contains("deliberate_unit_test_failure")); + + let skipped_id = host.broker.send( + "console", + Some("cargo"), + None, + None, + json!({"type": "build", "source": failing_test_source.display().to_string(), "skip_tests": true}), + ); + let skipped = receive_reply(&mut mailbox, &skipped_id, Duration::from_secs(120)).await; + assert_eq!(skipped.body["type"], "built", "{skipped:?}"); + assert_eq!(skipped.body["tests"]["passed"], 0); + assert_eq!(skipped.body["tests"]["failed"], 0); + assert_eq!(skipped.body["tests"]["output"], "skipped (skip_tests=true)"); + let invalid_source = cargo_source("cargo-fail"); let invalid_id = host.broker.send( "console", @@ -644,14 +675,10 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() { None, json!({"type": "build", "source": invalid_source.display().to_string()}), ); - let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await; - assert_eq!(failed.body["type"], "build_failed"); - assert!( - failed.body["errors"] - .as_str() - .unwrap() - .contains("missing_function") - ); + let invalid = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await; + assert_eq!(invalid.body["type"], "build_failed", "{invalid:?}"); + assert_eq!(invalid.body["tests"]["failed"], 1); + assert!(invalid.body["tests"]["output"].as_str().unwrap().contains("missing_function")); host.stop().await; }