Files
sausage/host/tests/it/builder.rs
T

400 lines
15 KiB
Rust

//! Real-world Cargo acceptance: native namespaces, source changes, cache, and WASI execution.
use sausage::{
exec::{Command, Exec, Options},
process::Runtime,
world::World,
};
use std::{
path::{Path, PathBuf},
sync::{Arc, OnceLock},
time::Duration,
};
fn programs() -> PathBuf {
static PATH: OnceLock<PathBuf> = OnceLock::new();
PATH.get_or_init(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
crate::common::cwd_init();
assert!(
std::process::Command::new("cargo")
.args(["build", "--release", "-p", "world-cargo"])
.current_dir(root.join("userland"))
.status()
.unwrap()
.success()
);
assert!(crate::common::build_brush().unwrap().success());
PathBuf::from(std::env::var_os("CARGO_TARGET_DIR").expect("executor target required"))
.join("wasm32-wasip2/release")
})
.clone()
}
async fn cargo(runtime: &Arc<Runtime>, args: &[&str]) -> anyhow::Result<(i32, String)> {
cargo_at(runtime, args, "/project").await
}
async fn cargo_at(
runtime: &Arc<Runtime>,
args: &[&str],
cwd: &str,
) -> anyhow::Result<(i32, String)> {
let mut bytes = Vec::new();
let code = runtime
.exec(
Command::Argv(
std::iter::once("/bin/cargo.wasm".into())
.chain(args.iter().map(|a| a.to_string()))
.collect(),
),
Options {
cwd: cwd.into(),
timeout: Some(Duration::from_secs(120)),
..Default::default()
},
&mut |_, b| bytes.extend_from_slice(b),
)
.await?;
Ok((code, String::from_utf8_lossy(&bytes).into_owned()))
}
#[tokio::test(flavor = "multi_thread")]
async fn cargo_scaffold_and_human_output() -> anyhow::Result<()> {
let binaries = programs();
let temp = tempfile::tempdir()?;
let world = World::open(temp.path())?;
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?;
let runtime = Runtime::new(world)?;
for (script, cwd, expected, success) in [
(
"cargo new hello && cd hello && cargo run",
"/",
"Hello, world!\n",
true,
),
("cargo build", "/hello", "", true),
(
"cargo new library --lib --name named_lib --edition 2021",
"/",
"",
true,
),
("cargo check", "/library", "", true),
(
"cargo init --lib --name initialized --edition 2024",
"/empty",
"",
true,
),
("cargo check", "/empty", "", true),
("cargo new broken --edition 1900", "/", "", false),
] {
std::fs::create_dir_all(temp.path().join("world/empty"))?;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let code = runtime
.exec(
Command::Argv(vec!["/bin/sh.wasm".into(), "-c".into(), script.into()]),
Options {
cwd: cwd.into(),
timeout: Some(Duration::from_secs(120)),
..Default::default()
},
&mut |stream, bytes| match stream {
sausage::exec::Stream::Stdout => stdout.extend_from_slice(bytes),
sausage::exec::Stream::Stderr => stderr.extend_from_slice(bytes),
},
)
.await?;
let stderr = String::from_utf8_lossy(&stderr);
assert_eq!(code == 0, success, "{script}: {stderr}");
assert_eq!(
String::from_utf8_lossy(&stdout),
expected,
"{script}: {stderr}"
);
assert!(
!stderr.contains("\"reason\":\"compiler-artifact\""),
"{stderr}"
);
assert!(!stderr.contains("/host/builds/"), "{stderr}");
assert!(!stderr.contains("tools/run.nu"), "{stderr}");
if script.contains("cargo run") {
assert!(stderr.contains("(/hello)"), "{stderr}");
}
if script == "cargo build" {
assert!(stderr.contains("Finished"), "{stderr}");
}
}
let root = temp.path().join("world");
let manifest: toml::Value =
toml::from_str(&std::fs::read_to_string(root.join("library/Cargo.toml"))?)?;
assert_eq!(manifest["package"]["name"].as_str(), Some("named_lib"));
assert_eq!(manifest["package"]["edition"].as_str(), Some("2021"));
assert!(root.join("library/src/lib.rs").is_file());
assert!(root.join("empty/src/lib.rs").is_file());
Ok(())
}
fn cache_hits(base: &Path) -> anyhow::Result<u64> {
let stats = std::process::Command::new("kache")
.arg("stats")
.env("KACHE_CACHE_DIR", base.join("kache"))
.output()?;
assert!(
stats.status.success(),
"{}",
String::from_utf8_lossy(&stats.stderr)
);
let text = String::from_utf8_lossy(&stats.stdout);
println!("PRIVATE CACHE: {text}");
let words: Vec<_> = text.split_whitespace().collect();
let hits = words.windows(2).find_map(|pair| {
(pair[1] == "hits")
.then(|| pair[0].replace(',', "").parse::<u64>().ok())
.flatten()
});
assert!(
std::process::Command::new("kache")
.args(["daemon", "stop"])
.env("KACHE_CACHE_DIR", base.join("kache"))
.status()?
.success()
);
Ok(hits.expect("kache stats must report its lifetime hits"))
}
#[tokio::test(flavor = "multi_thread")]
async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> {
let binaries = programs();
let temp = tempfile::tempdir()?;
let world = World::open(temp.path())?;
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?;
let source = temp.path().join("world/project");
std::fs::create_dir_all(source.join("src"))?;
std::fs::create_dir_all(source.join("tests"))?;
std::fs::create_dir_all(source.join("target"))?;
std::fs::write(source.join("target/keep.txt"), "existing target")?;
std::os::unix::fs::symlink("missing", source.join("dangling"))?;
std::fs::create_dir_all(temp.path().join("host/builds/incomplete/source"))?;
for (path, bytes) in [
(
"Cargo.toml",
include_bytes!("../builder-fixture/Cargo.toml").as_slice(),
),
(
"build.rs",
include_bytes!("../builder-fixture/build.rs").as_slice(),
),
(
"src/main.rs",
include_bytes!("../builder-fixture/src/main.rs").as_slice(),
),
(
"tests/integration.rs",
include_bytes!("../builder-fixture/tests/integration.rs").as_slice(),
),
(
"src/add.c",
include_bytes!("../builder-fixture/src/add.c").as_slice(),
),
] {
std::fs::write(source.join(path), bytes)?;
}
let runtime = Runtime::new(world)?;
let (code, out) = cargo(&runtime, &["build"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("namespace assertions passed"), "{out}");
assert!(out.contains("(/project)"), "{out}");
assert!(!out.contains("/host/builds/"), "{out}");
assert_eq!(
std::fs::read_link(source.join("dangling"))?,
Path::new("missing")
);
assert_eq!(
std::fs::read_to_string(source.join("target/keep.txt"))?,
"existing target"
);
assert!(source.join("Cargo.lock").exists());
let mut shell_output = Vec::new();
let shell_code = runtime
.exec(
Command::Argv(vec![
"/bin/sh.wasm".into(),
"-c".into(),
"./target/wasm32-wasip2/debug/builder-fixture.wasm".into(),
]),
Options {
cwd: "/project".into(),
..Default::default()
},
&mut |_, b| shell_output.extend_from_slice(b),
)
.await?;
assert_eq!(shell_code, 0, "{}", String::from_utf8_lossy(&shell_output));
assert!(String::from_utf8_lossy(&shell_output).contains("builder fixture cwd: /project"));
let (code, out) = cargo(&runtime, &["run"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("builder fixture cwd: /project"), "{out}");
let (code, out) = cargo(&runtime, &["run", "--", "--no-run"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("builder fixture cwd: /project"), "{out}");
let (code, out) = cargo(&runtime, &["run", "--", "--exit"]).await?;
assert_eq!(code, 23, "{out}");
let (code, out) = cargo(&runtime, &["test", "pass", "--", "--nocapture"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("1 passed"), "{out}");
assert!(out.contains("INTEGRATION_CASE"), "{out}");
let (code, out) = cargo_at(
&runtime,
&["test", "pass", "--", "--nocapture"],
"/project/src",
)
.await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("INTEGRATION_CASE"), "{out}");
// WASI aborts on panic, so disable capture to expose the panic message.
let (code, out) = cargo(&runtime, &["test", "fail", "--", "--nocapture"]).await?;
assert_eq!(code, 101, "{out}");
assert!(out.contains("intentional fixture failure"), "{out}");
let (code, out) = cargo(&runtime, &["test", "--no-run"]).await?;
assert_eq!(code, 0, "{out}");
assert!(!out.contains("intentional fixture failure"), "{out}");
std::fs::create_dir_all(source.join("src/bin"))?;
std::fs::write(
source.join("src/bin/other.rs"),
"fn main() { println!(\"OTHER_BIN\"); }\n",
)?;
let (code, out) = cargo(&runtime, &["run"]).await?;
assert_eq!(code, 101, "{out}");
assert!(out.contains("--bin"), "{out}");
let manifest = std::fs::read_to_string(source.join("Cargo.toml"))?;
std::fs::write(
source.join("Cargo.toml"),
manifest.replace("[package]\n", "[package]\ndefault-run = \"other\"\n"),
)?;
let (code, out) = cargo(&runtime, &["run"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("OTHER_BIN"), "{out}");
let (code, out) = cargo(&runtime, &["run", "--bin", "builder-fixture"]).await?;
assert_eq!(code, 0, "{out}");
assert!(out.contains("builder fixture cwd: /project"), "{out}");
for action in ["check", "clippy", "fetch", "tree", "metadata", "update"] {
let (code, out) = cargo(&runtime, &[action]).await?;
assert_eq!(code, 0, "{action}: {out}");
}
let (code, out) = cargo(&runtime, &["clippy", "--", "-D", "clippy::print_stdout"]).await?;
assert_ne!(code, 0, "{out}");
assert!(out.contains("stdout"), "{out}");
let base = std::fs::read_dir(temp.path().join("host/builds"))?
.collect::<std::io::Result<Vec<_>>>()?
.into_iter()
.find(|entry| entry.path().join("last-used").is_file())
.unwrap()
.path();
let before = cache_hits(&base)?;
let (code, out) = cargo(&runtime, &["clean"]).await?;
assert_eq!(code, 0, "{out}");
assert!(
!source
.join("target/wasm32-wasip2/debug/builder-fixture.wasm")
.exists()
);
let (code, out) = cargo(&runtime, &["build"]).await?;
assert_eq!(code, 0, "{out}");
let after = cache_hits(&base)?;
println!("CACHE AFTER CLEAN: {before} -> {after} hits");
assert!(
after > before,
"the second build did not restore cached actions"
);
std::fs::write(
source.join("src/main.rs"),
"fn main(){println!(\"format me\");}\n",
)?;
let unformatted = std::fs::read(source.join("src/main.rs"))?;
let (code, out) = cargo(&runtime, &["fmt", "--", "--check"]).await?;
assert_ne!(code, 0, "{out}");
assert_eq!(std::fs::read(source.join("src/main.rs"))?, unformatted);
let (code, out) = cargo(&runtime, &["fmt"]).await?;
assert_eq!(code, 0, "{out}");
assert!(std::fs::read_to_string(source.join("src/main.rs"))?.contains("fn main() {"));
let (code, out) = cargo(&runtime, &["remove", "serde"]).await?;
assert_eq!(code, 0, "{out}");
let (code, out) = cargo(&runtime, &["add", "serde"]).await?;
assert_eq!(code, 0, "{out}");
assert!(std::fs::read_to_string(source.join("Cargo.toml"))?.contains("serde"));
std::fs::write(
source.join("src/main.rs"),
"fn main() { undefined_function(); }",
)?;
let (code, out) = cargo(&runtime, &["build"]).await?;
assert_ne!(code, 0, "{out}");
assert!(out.contains("undefined_function"), "{out}");
Ok(())
}
#[tokio::test]
async fn timeout_and_cancellation() -> anyhow::Result<()> {
use sausage::builder::{Builder, Event, Request};
let temp = tempfile::tempdir()?;
let world = World::open(temp.path())?;
let source = temp.path().join("world/project");
std::fs::create_dir_all(source.join("src"))?;
std::fs::write(
source.join("Cargo.toml"),
"[package]\nname='hang'\nversion='0.1.0'\n[workspace]\n",
)?;
std::fs::write(source.join("src/main.rs"), "fn main() {}")?;
std::fs::write(
source.join("build.rs"),
"fn main() { std::process::Command::new(\"sh\").args([\"-c\", \"echo $$ > child.pid; exec sleep 1000\"]).status().unwrap(); }",
)?;
let builder = Arc::new(Builder::new(world.root.clone(), &world.host_dir)?);
let (tx, mut rx) = tokio::sync::mpsc::channel(16);
let task = tokio::spawn(async move {
builder
.execute(
Request {
argv: vec!["build".into()],
cwd: "/project".into(),
timeout_secs: Some(3),
},
&tx,
)
.await
});
let mut timed_out = false;
while let Some(event) = rx.recv().await {
if let Event::Exit { code, .. } = event {
timed_out = code == 124;
}
}
task.await??;
assert!(timed_out);
let binaries = programs();
let bin = temp.path().join("world/bin");
std::fs::create_dir_all(&bin)?;
std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?;
let runtime = Runtime::new(world)?;
let error = runtime
.exec(
Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]),
Options {
cwd: "/project".into(),
timeout: Some(Duration::from_secs(1)),
..Default::default()
},
&mut |_, _| {},
)
.await
.unwrap_err();
assert!(error.to_string().contains("timed out"), "{error}");
let (code, out) =
tokio::time::timeout(Duration::from_secs(20), cargo(&runtime, &["metadata"])).await??;
assert_eq!(
code, 0,
"the canceled build still held the builder lock: {out}"
);
Ok(())
}