- cargo.build (native): one source copy, tests under WASI, release build to /state/cargo/builds/<n>/, process groups killed on cancel. The host writes the SDK copy to /plugins/cargo/sdk/ at start. - sausage test-runner: one instance per test, in parallel, with its own mount table (empty world, package folder at its path); answers core.lock, core.unlock, core.notify, core.log. - The console: rustyline, notifications while typing, ls/cat through the process view, methods, ps, log, activate, revert, stop, kill. - core.notify on stdout is handled in order, before the response.
350 lines
12 KiB
Rust
350 lines
12 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 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 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::<u32>()
|
|
{
|
|
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;
|
|
}
|