From 8d058666dcb8da35c0087c239910a0d1980fc3a0 Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 23 Oct 2026 01:42:19 +0100 Subject: [PATCH] cargo.build, the WASI test runner, the console, and core.notify ordering - cargo.build (native): one source copy, tests under WASI, release build to /state/cargo/builds//, 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. --- Cargo.lock | 115 ++++ cli/Cargo.toml | 4 + cli/src/cargo.rs | 524 ++++++++++--------- cli/src/console.rs | 339 +++++++++++- cli/src/host.rs | 4 + cli/src/main.rs | 34 +- cli/src/routing.rs | 24 + cli/src/runtime.rs | 11 + cli/src/test_runner.rs | 237 +++++++++ cli/tests/cargo.rs | 349 ++++++++++++ cli/tests/console.rs | 255 +++++++++ cli/tests/core.rs | 28 + cli/tests/crates/cargo-c/Cargo.toml | 2 +- cli/tests/crates/cargo-c/src/main.rs | 22 +- cli/tests/crates/cargo-hang/Cargo.toml | 6 + cli/tests/crates/cargo-hang/build.rs | 7 + cli/tests/crates/cargo-hang/src/main.rs | 1 + cli/tests/crates/cargo-test-fail/src/main.rs | 10 +- cli/tests/crates/cargo-valid/Cargo.toml | 3 + cli/tests/crates/cargo-valid/fixture.txt | 1 + cli/tests/crates/cargo-valid/src/main.rs | 71 ++- cli/tests/plugins/probe/src/main.rs | 31 ++ interface/src/lib.rs | 4 +- 23 files changed, 1786 insertions(+), 296 deletions(-) create mode 100644 cli/src/test_runner.rs create mode 100644 cli/tests/cargo.rs create mode 100644 cli/tests/console.rs create mode 100644 cli/tests/crates/cargo-hang/Cargo.toml create mode 100644 cli/tests/crates/cargo-hang/build.rs create mode 100644 cli/tests/crates/cargo-hang/src/main.rs create mode 100644 cli/tests/crates/cargo-valid/fixture.txt diff --git a/Cargo.lock b/Cargo.lock index f91e598..024e549 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,16 @@ dependencies = [ "gimli", ] +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "sausage-plugin", + "serde", + "serde_json", + "wstd", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -310,6 +320,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -637,6 +656,12 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "equivalent" version = "1.0.3" @@ -653,6 +678,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -957,6 +988,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.5.0" @@ -1024,6 +1064,20 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1429,6 +1483,27 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "notify" version = "8.2.0" @@ -1659,6 +1734,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.10.3" @@ -1903,6 +1988,26 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustyline" +version = "19.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e1d6bd81c2d5feafd2419138df9183c45a10a492c4e873b8b0acb8eb2b549e" +dependencies = [ + "bitflags", + "clipboard-win", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.61.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1921,9 +2026,13 @@ dependencies = [ "clap", "http", "http-body-util", + "hyper", + "hyper-util", "include_dir", + "nix", "notify", "rusqlite", + "rustyline", "sausage-interface", "sausage-vfs", "serde", @@ -2573,6 +2682,12 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 674cb0d..642e0de 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -29,6 +29,10 @@ rusqlite = { version = "0.38", features = ["bundled"] } sausage-vfs = { version = "0.1.0", path = "../vfs" } sausage-interface = { version = "0.1.0", path = "../interface" } notify = "8.2.0" +rustyline = "19" +nix = { version = "0.31.3", features = ["signal", "process"] } [dev-dependencies] +hyper = { version = "1.12.0", features = ["server", "http1"] } +hyper-util = { version = "0.1.21", features = ["tokio"] } sausage-interface = { path = "../interface" } diff --git a/cli/src/cargo.rs b/cli/src/cargo.rs index e25645b..16f1ea2 100644 --- a/cli/src/cargo.rs +++ b/cli/src/cargo.rs @@ -1,69 +1,48 @@ -//! Rust build helpers for the native cargo plugin. - -use std::path::{Path, PathBuf}; -use std::time::Instant; - -use anyhow::{Context, Result, anyhow, bail}; -use serde_json::Value; +//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation. +use crate::{Host, world::World}; +use anyhow::{Context, Result, bail}; +use include_dir::{Dir, include_dir}; +use serde_json::{Value, json}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use tokio::process::Command; -use include_dir::{Dir, include_dir}; - -use crate::world::{self, World}; - -/// The name reserved for the native Rust build plugin. +/// The native build plugin. pub const ID: &str = "cargo"; -/// The WASI component target used for Rust plugin builds. +/// The target used for builds and tests. pub const TARGET: &str = "wasm32-wasip2"; +static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src"); +static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src"); +static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src"); -/// The source of the `sausage-actor` SDK. -static SDK_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src"); -static SDK_MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src"); - -#[derive(Clone, Default, Debug)] -/// The totals and output from a source tree's test run. +#[derive(Clone, Default, Debug, serde::Serialize)] +/// Test totals and captured output. pub struct TestResults { - /// The number of tests that passed. pub passed: u64, - /// The number of tests that failed. pub failed: u64, - /// The test output retained for the caller. pub output: String, } - -/// The component artifact and diagnostics from a successful build. -pub struct BuildOutput { - /// Host path of the published `.wasm` file. - pub wasm: PathBuf, - /// The elapsed build time in seconds. - pub seconds: f64, - /// The compiler diagnostics without progress lines. - pub warnings: String, - /// The totals and output of the test phase. - pub tests: TestResults, -} - +/// A successful build, usable without a running host. #[derive(Debug)] -/// The failed build or test phase and its diagnostics. +pub struct BuildOutput { + pub wasm: PathBuf, + pub warnings: String, + pub tests: TestResults, +} +/// A failed compiler or test phase, with its complete diagnostics. +#[derive(Debug)] pub struct BuildFailure { - /// The reason that the build could not complete. pub reason: String, - /// The totals and output of the test phase. 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) - } + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}\n{}", self.reason, self.tests.output) } } - impl std::error::Error for BuildFailure {} - impl From for BuildFailure { fn from(error: anyhow::Error) -> Self { Self { @@ -72,170 +51,271 @@ impl From for BuildFailure { } } } - -/// All builds share one target folder, so they run one at a time. +/// A host-independent builder. Each builder serializes access to its work and target folders. pub struct Cargo { world: World, + builds: tokio::sync::Mutex<()>, + runner: PathBuf, } - impl Cargo { - /// Create the plugin and install the SDK of this host into `public/sdk/`. + /// Installs changed SDK files and selects the current sausage executable as runner. pub fn new(world: World) -> Result { - let sdk = world.plugin_dir(ID).join("sdk"); - write_dir(&SDK_SRC, &sdk.join("src"))?; - write_dir(&SDK_MACROS, &sdk.join("macros/src"))?; - world::write_atomic( - &sdk.join("Cargo.toml"), - include_bytes!("../../sdk/Cargo.toml"), - )?; - world::write_atomic( - &sdk.join("macros/Cargo.toml"), - include_bytes!("../../sdk/macros/Cargo.toml"), - )?; - Ok(Self { world }) - } - - /// Run native `cargo test` unless opted out, then build and publish the - /// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds//`. - pub async fn build( - &self, - source: &Path, - skip_tests: bool, - ) -> std::result::Result { - if !source.join("Cargo.toml").is_file() { - return Err(BuildFailure { - reason: format!("{} has no Cargo.toml", source.display()), - tests: TestResults::default(), - }); + let mut runner = std::env::current_exe()?; + if runner + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps")) + { + runner = runner.parent().unwrap().parent().unwrap().join("sausage"); } - let started = Instant::now(); - let data = self.world.state_dir(ID); - let build_dir = data.join("work"); - world::replace_source(source, &build_dir)?; - let manifest = build_dir.join("Cargo.toml"); - let original = std::fs::read_to_string(&manifest).context("read build manifest")?; - let prepared = prepare_manifest(&original, Some(&self.world.plugin_dir(ID).join("sdk")))?; - std::fs::write(&manifest, prepared).context("write prepared manifest")?; - - 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() - }, - }); + Self::with_runner(world, runner) + } + /// Installs the SDK and accepts an explicit sausage binary, including for init integrations. + pub fn with_runner(world: World, runner: PathBuf) -> Result { + let sdk = world.plugin_dir(ID).join("sdk"); + write_dir(&SDK, &sdk.join("src"))?; + write_dir(&MACROS, &sdk.join("macros/src"))?; + write_dir(&INTERFACE, &sdk.join("interface/src"))?; + for (manifest, destination, interface_path) in [ + ( + include_str!("../../sdk/Cargo.toml"), + sdk.join("Cargo.toml"), + Some("interface"), + ), + ( + include_str!("../../sdk/macros/Cargo.toml"), + sdk.join("macros/Cargo.toml"), + Some("../interface"), + ), + ( + include_str!("../../interface/Cargo.toml"), + sdk.join("interface/Cargo.toml"), + None, + ), + ] { + let mut document: toml_edit::DocumentMut = manifest.parse()?; + document["workspace"] = toml_edit::table(); + if let Some(path) = interface_path { + for section in ["dependencies", "dev-dependencies"] { + if let Some(dependency) = document + .get_mut(section) + .and_then(|table| table.get_mut("sausage-interface")) + { + dependency["path"] = toml_edit::value(path); + } } } + write_changed(&destination, document.to_string().as_bytes())?; + } + Ok(Self { + world, + runner, + builds: tokio::sync::Mutex::new(()), + }) + } + /// Registers cargo.build as a cancellable native process. + pub fn register(self, host: &Host) { + let builder = Arc::new(self); + host.register_native(ID,Arc::new(move |_context,method,params| { + let builder=builder.clone(); + Box::pin(async move { + if method!="build" { return Err(crate::error(-32601,"cargo method not found")); } + let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?; + match builder.build(&builder.world.plugin_dir(plugin).join("source")).await { + Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings})), + Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"tests":failure.tests}})), + } + }) + })); + } + /// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds. + pub async fn build(&self, source: &Path) -> std::result::Result { + let _build = self.builds.lock().await; + let work = self.world.state_dir(ID).join("work"); + let target = self.world.state_dir(ID).join("target"); + let source = source.to_path_buf(); + let copy = work.clone(); + let sdk = self.world.plugin_dir(ID).join("sdk"); + tokio::task::spawn_blocking(move || -> Result<()> { + if copy.exists() { + std::fs::remove_dir_all(©)?; + } + copy_source(&source, ©)?; + let manifest = copy.join("Cargo.toml"); + let text = std::fs::read_to_string(&manifest)?; + std::fs::write(manifest, prepare_manifest(&text, &sdk)?)?; + Ok(()) + }) + .await + .map_err(anyhow::Error::from)??; + let mut test = Command::new("cargo"); + test.args(["test", "--offline", "--target", TARGET, "--target-dir"]) + .arg(&target) + .current_dir(&work) + .env( + "CARGO_TARGET_WASM32_WASIP2_RUNNER", + format!("{} test-runner", self.runner.display()), + ); + let output = run_command(test).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let tests = TestResults { + passed: test_count(&stdout, "passed"), + failed: if output.status.success() { + 0 + } else { + test_count(&stdout, "failed").max(1) + }, + output: format!("{stdout}\n{stderr}"), }; - if tests.failed > 0 { + if !output.status.success() { return Err(BuildFailure { - reason: "unit tests failed".into(), + reason: "WASI tests failed".into(), tests, }); } - - 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 result = async { + let mut build = Command::new("cargo"); + build + .args([ + "build", + "--offline", + "--release", + "--target", + TARGET, + "--message-format=json-render-diagnostics", + "--target-dir", + ]) + .arg(&target) + .current_dir(&work); + let output = run_command(build).await?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let mut diagnostics = stderr.to_string(); 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)); + for line in stdout.lines() { + if let Ok(message) = serde_json::from_str::(line) { + if message["reason"] == "compiler-artifact" + && let Some(executable) = message["executable"].as_str() + { + artifact = Some(PathBuf::from(executable)); + } + if let Some(rendered) = message["message"]["rendered"].as_str() { + diagnostics.push_str(rendered); + } } } - 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.plugin_dir(ID).join("builds"))?; - let dest_dir = self.world.plugin_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)?)?; + if !output.status.success() { + bail!("{diagnostics}"); + } + let artifact = artifact.context("cargo built no binary")?; + let builds = self.world.state_dir(ID).join("builds"); + std::fs::create_dir_all(&builds)?; + let number = std::fs::read_dir(&builds)? + .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::().ok()) + .max() + .unwrap_or(0) + + 1; + let destination = builds + .join(number.to_string()) + .join(artifact.file_name().unwrap()); + crate::world::write_atomic(&destination, &tokio::fs::read(artifact).await?)?; Ok(BuildOutput { - wasm: dest, - seconds: started.elapsed().as_secs_f64(), - warnings: strip_progress(&stderr), + wasm: destination, + warnings: strip_progress(&diagnostics), tests: tests.clone(), }) } .await; - build_result.map_err(|error| BuildFailure { + result.map_err(|error: anyhow::Error| BuildFailure { reason: format!("{error:#}"), tests, }) } } - -/// 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; +/// Kills cargo and every descendant, including tests and build scripts, on cancellation. +struct ProcessGroup(u32); +impl Drop for ProcessGroup { + fn drop(&mut self) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(self.0 as i32), + nix::sys::signal::Signal::SIGKILL, + ); } - Ok(TestResults { - passed, - failed, - output, - }) } - -/// Count the `passed` or `failed` totals in cargo's per-target test summaries. +/// Starts one process group and retains its guard across all output collection. +async fn run_command(mut command: Command) -> Result { + command + .process_group(0) + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let child = command.spawn().context("start cargo")?; + let _group = ProcessGroup(child.id().unwrap()); + Ok(child.wait_with_output().await?) +} +/// Copies only source, excluding build outputs and git internals at every level. +fn copy_source(source: &Path, destination: &Path) -> Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + if matches!(entry.file_name().to_str(), Some("target" | ".git")) { + continue; + } + if entry.file_type()?.is_dir() { + copy_source(&entry.path(), &destination.join(entry.file_name()))?; + } else { + std::fs::copy(entry.path(), destination.join(entry.file_name()))?; + } + } + Ok(()) +} +/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace. +fn prepare_manifest(text: &str, sdk: &Path) -> Result { + let mut document: toml_edit::DocumentMut = text.parse()?; + for section in ["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(dependency) = document + .get_mut(section) + .and_then(|table| table.get_mut("sausage-plugin")) + { + let features = dependency.get("features").cloned(); + let defaults = dependency.get("default-features").cloned(); + let mut replacement = toml_edit::InlineTable::new(); + replacement.insert("path", sdk.display().to_string().into()); + if let Some(features) = features.and_then(|value| value.as_value().cloned()) { + replacement.insert("features", features); + } + if let Some(defaults) = defaults.and_then(|value| value.as_value().cloned()) { + replacement.insert("default-features", defaults); + } + *dependency = toml_edit::value(replacement); + } + } + document["workspace"] = toml_edit::table(); + Ok(document.to_string()) +} +/// Writes embedded sources only when their bytes changed. +fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> { + if std::fs::read(path).ok().as_deref() != Some(bytes) { + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(path, bytes)?; + } + Ok(()) +} +/// Installs one embedded source tree without touching unchanged files. +fn write_dir(directory: &Dir<'_>, destination: &Path) -> Result<()> { + for file in directory.files() { + write_changed(&destination.join(file.path()), file.contents())?; + } + for directory in directory.dirs() { + write_dir(directory, destination)?; + } + Ok(()) +} +/// Counts only final libtest summaries, never individual status lines. fn test_count(output: &str, field: &str) -> u64 { output .lines() - .filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest)) + .filter_map(|line| line.strip_prefix("test result:")) .flat_map(|summary| summary.split(';')) .filter_map(|part| { let words: Vec<_> = part.split_whitespace().collect(); @@ -244,77 +324,21 @@ fn test_count(output: &str, field: &str) -> u64 { }) .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 { - let mut doc: toml_edit::DocumentMut = text.parse()?; - if let Some(sdk) = sdk { - for section in ["dependencies", "dev-dependencies", "build-dependencies"] { - let Some(dep) = doc - .get_mut(section) - .and_then(|t| t.get_mut("sausage-actor")) - else { - continue; - }; - let mut table = dep.as_inline_table().cloned().unwrap_or_default(); - for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] { - table.remove(key); - } - table.insert("path", sdk.display().to_string().into()); - *dep = toml_edit::value(table); - } - } - if doc.get("workspace").is_none() { - doc["workspace"] = toml_edit::table(); - } - Ok(doc.to_string()) -} - -/// Write the files of `dir` below `to`. Skip files that did not change, so -/// that cargo does not rebuild the SDK at every start. -fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> { - for file in dir.files() { - let dest = to.join(file.path()); - let contents = match file.path().file_name() { - Some(n) if n == "Cargo.toml" => { - prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes() - } - _ => file.contents().to_vec(), - }; - if std::fs::read(&dest).ok() != Some(contents.clone()) { - std::fs::create_dir_all(dest.parent().context("no parent")?)?; - std::fs::write(&dest, contents)?; - } - } - for sub in dir.dirs() { - write_dir(sub, to)?; - } - Ok(()) -} - -fn next_build_number(dir: &Path) -> Result { - std::fs::create_dir_all(dir)?; - let max = std::fs::read_dir(dir)? - .filter_map(|e| e.ok()?.file_name().to_str()?.parse::().ok()) - .max() - .unwrap_or(0); - Ok(max + 1) -} - -/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics. -fn strip_progress(stderr: &str) -> String { - stderr +/// Removes progress lines while retaining compiler diagnostics. +fn strip_progress(output: &str) -> String { + output .lines() - .filter(|l| { - let t = l.trim_start(); - !(t.starts_with("Compiling ") - || t.starts_with("Downloaded ") - || t.starts_with("Downloading ") - || t.starts_with("Updating ") - || t.starts_with("Locking ") - || t.starts_with("Adding ") - || t.starts_with("Blocking ")) + .filter(|line| { + ![ + "Compiling ", + "Finished ", + "Blocking ", + "Updating ", + "Locking ", + "Adding ", + ] + .iter() + .any(|prefix| line.trim_start().starts_with(prefix)) }) .collect::>() .join("\n") diff --git a/cli/src/console.rs b/cli/src/console.rs index cba57eb..3feb7a7 100644 --- a/cli/src/console.rs +++ b/cli/src/console.rs @@ -1,24 +1,325 @@ -//! The minimal console reads explicit JSON-RPC calls and the quit command. +//! Console commands, readable results, and line editing with live notifications. use crate::Host; -use tokio::io::{AsyncBufReadExt, BufReader}; +use anyhow::{Result, bail}; +use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError}; +use sausage_vfs::{Layer, Open}; +use serde_json::{Value, json}; +use std::time::{SystemTime, UNIX_EPOCH}; -/// Read console lines until EOF or quit, and print each call's result. -pub async fn run_input(host: Host) -> anyhow::Result<()> { - let mut lines = BufReader::new(tokio::io::stdin()).lines(); - while let Some(line) = lines.next_line().await? { - if line == "quit" { - break; - } - let (method, params) = line.split_once(' ').unwrap_or((&line, "{}")); - match serde_json::from_str(params) { - Ok(params) => { - let value = match host.call(method, params).await { - Ok(value) | Err(value) => value, - }; - println!("{value}"); - } - Err(error) => eprintln!("{error}"), +const HELP: &str = "text chat with the agent +plugin.method {json} call a plugin +ls [path] list a folder (default /) +cat read a file +methods show methods, params, types, and docs +ps show pid, plugin, method, parent, state, age +log [plugin or pid] [-n N] read the journal +activate activate a version +revert restore the previous version +stop request a graceful stop +kill kill a process +help show this help +quit leave the console"; + +async fn read(host: &Host, path: &str) -> Result> { + let fs = host.filesystem().await?; + let file = fs + .open( + path, + Open { + follow: true, + ..Open::default() + }, + ) + .await?; + let mut bytes = Vec::new(); + loop { + let chunk = file.read(bytes.len() as u64, 65536).await?; + if chunk.is_empty() { + return Ok(bytes); } + bytes.extend(chunk); } - Ok(()) +} +fn pretty(value: &Value) -> Result { + Ok(serde_json::to_string_pretty(value)?) +} +async fn call(host: &Host, method: &str, params: Value, chat: bool) -> Result { + match host.call(method, params).await { + Ok(value) if chat => Ok(value["text"] + .as_str() + .or_else(|| value.as_str()) + .map(str::to_owned) + .unwrap_or(pretty(&value)?)), + Ok(value) => pretty(&value), + Err(error) => Ok(format!("error: {}", pretty(&error)?)), + } +} + +/// Execute one input line without terminal IO. The caller handles quit after printing. +pub async fn execute_line(host: &Host, line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Ok(String::new()); + } + let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); + let rest = rest.trim(); + let words: Vec<_> = rest.split_whitespace().collect(); + match command { + "help" if words.is_empty() => Ok(HELP.into()), + "quit" if words.is_empty() => Ok("Goodbye.".into()), + "ls" if words.len() <= 1 => { + let fs = host.filesystem().await?; + let mut entries = fs.list(words.first().copied().unwrap_or("/")).await?; + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(entries + .into_iter() + .map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" })) + .collect::>() + .join("\n")) + } + "cat" if !rest.is_empty() => Ok(String::from_utf8(read(host, rest).await?)?), + "methods" if words.len() == 1 => { + host.refresh_graph() + .await + .map_err(|error| anyhow::anyhow!("{error}"))?; + let document: Value = serde_json::from_slice( + &read( + host, + &format!("/proc/core/plugins/{}/discover.json", words[0]), + ) + .await?, + )?; + let mut lines = Vec::new(); + for method in document["methods"] + .as_array() + .ok_or_else(|| anyhow::anyhow!("discovery document has no methods"))? + { + let params = method["params"] + .as_array() + .map(|params| { + params + .iter() + .map(|p| { + format!( + "{}: {}", + p["name"].as_str().unwrap_or("?"), + p["schema"]["type"].as_str().unwrap_or("any") + ) + }) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + lines.push(format!( + "{}.{}({}) -> {}", + words[0], + method["name"].as_str().unwrap_or("?"), + params, + method["result"]["schema"]["type"].as_str().unwrap_or("any") + )); + for field in ["summary", "description"] { + if let Some(doc) = method[field].as_str() { + lines.push(format!(" {doc}")); + } + } + if let Some(params) = method["params"].as_array() { + for p in params { + if let Some(doc) = p["description"].as_str() { + lines.push(format!(" {}: {doc}", p["name"].as_str().unwrap_or("?"))); + } + } + } + } + Ok(lines.join("\n")) + } + "ps" if words.is_empty() => { + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let mut processes: Vec<_> = host.processes().into_iter().collect(); + processes.sort_by(|a, b| a.0.cmp(&b.0)); + let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()]; + lines.extend(processes.into_iter().map(|(pid, p)| { + format!( + "{} {} {} {} {} {}s", + pid, + p.plugin, + p.method, + p.parent.as_deref().unwrap_or("-"), + p.state, + now.saturating_sub(p.start_time) / 1000 + ) + })); + Ok(lines.join("\n")) + } + "log" => { + let mut params = json!({}); + let mut i = 0; + while i < words.len() { + if words[i] == "-n" { + i += 1; + let count: u64 = words + .get(i) + .ok_or_else(|| anyhow::anyhow!("log -n requires a count"))? + .parse()?; + params["limit"] = json!(count); + } else if params.get("pid").is_none() && params.get("plugin").is_none() { + let key = if words[i].len() > 36 + && words[i] + .get(words[i].len() - 36..) + .is_some_and(|suffix| uuid::Uuid::try_parse(suffix).is_ok()) + { + "pid" + } else { + "plugin" + }; + params[key] = json!(words[i]); + } else { + bail!("usage: log [plugin or pid] [-n N]"); + } + i += 1; + } + call(host, "core.log", params, false).await + } + "activate" if words.len() == 2 => { + call( + host, + "core.activate", + json!({"plugin":words[0],"wasm":words[1]}), + false, + ) + .await + } + "revert" if words.len() == 1 => { + call(host, "core.revert", json!({"plugin":words[0]}), false).await + } + "stop" | "kill" if words.len() == 1 => { + call( + host, + &format!("core.{command}"), + json!({"pid":words[0]}), + false, + ) + .await + } + "help" | "quit" | "ls" | "methods" | "ps" | "activate" | "revert" | "stop" | "kill" + | "cat" => bail!("invalid arguments for {command}; use help"), + _ if command + .split_once('.') + .is_some_and(|(plugin, method)| !plugin.is_empty() && !method.is_empty()) => + { + call( + host, + command, + if rest.is_empty() { + json!({}) + } else { + serde_json::from_str(rest)? + }, + false, + ) + .await + } + _ => call(host, "agent.chat", json!({"text":line}), true).await, + } +} + +/// Format text notifications dimly, while preserving other notification payloads. +pub fn notification_text(notification: &Value) -> String { + let params = ¬ification["params"]; + if params["name"] == "text" { + let payload = ¶ms["params"]; + let text = payload["text"] + .as_str() + .or_else(|| payload.as_str()) + .map(str::to_owned) + .unwrap_or_else(|| payload.to_string()); + format!("\x1b[2m{text}\x1b[0m") + } else { + notification.to_string() + } +} +fn indent(text: &str) -> String { + text.lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n") +} + +/// Run the line editor on a dedicated thread, with notifications through its external printer. +pub async fn run_input(host: Host) -> Result<()> { + let history = host.0.world.state_dir("core").join("history"); + let (ready, started) = tokio::sync::oneshot::channel(); + let (sender, mut lines) = tokio::sync::mpsc::unbounded_channel::< + Result<(String, tokio::sync::oneshot::Sender)>, + >(); + // A detached terminal thread does not hold Tokio shutdown open at a blocked readline. + std::thread::spawn(move || { + let setup = (|| -> Result<_> { + let mut editor = DefaultEditor::new()?; + let _ = editor.load_history(&history); + let printer = editor.create_external_printer()?; + Ok((editor, printer)) + })(); + let (mut editor, printer) = match setup { + Ok(value) => value, + Err(error) => { + let _ = ready.send(Err(error)); + return; + } + }; + if ready.send(Ok(printer)).is_err() { + return; + } + let result = (|| -> Result<()> { + loop { + match editor.readline("> ") { + Ok(line) => { + editor.add_history_entry(&line)?; + editor.save_history(&history)?; + let (done, wait) = tokio::sync::oneshot::channel(); + if sender.send(Ok((line, done))).is_err() + || !wait.blocking_recv().unwrap_or(false) + { + break; + } + } + Err(ReadlineError::Interrupted) => break, + Err(ReadlineError::Eof) => break, + Err(error) => return Err(error.into()), + } + } + Ok(()) + })(); + if let Err(error) = result { + let _ = sender.send(Err(error)); + } + }); + let mut printer = started.await??; + let mut notifications = host.subscribe(); + let print_notifications = async move { + loop { + match notifications.recv().await { + Ok(value) => printer.print(format!("{}\n", indent(¬ification_text(&value))))?, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()), + } + } + }; + let commands = async { + while let Some(line) = lines.recv().await { + let (line, done) = line?; + let text = execute_line(&host, &line) + .await + .unwrap_or_else(|error| format!("error: {error}")); + if !text.is_empty() { + println!("{}", indent(&text)); + } + let quit = line.trim() == "quit"; + let _ = done.send(!quit); + if quit { + break; + } + } + Ok(()) + }; + tokio::select! {result=commands=>result,result=print_notifications=>result} } diff --git a/cli/src/host.rs b/cli/src/host.rs index 7b0b4a9..c2a0f3c 100644 --- a/cli/src/host.rs +++ b/cli/src/host.rs @@ -82,6 +82,10 @@ impl Host { watcher: Mutex::new(None), retirements: Mutex::new(std::collections::HashSet::new()), })); + let cargo_world = host.0.world.clone(); + let cargo = + tokio::task::spawn_blocking(move || crate::cargo::Cargo::new(cargo_world)).await??; + cargo.register(&host); host.boot().await?; Ok(host) } diff --git a/cli/src/main.rs b/cli/src/main.rs index 45c28fd..f7cab11 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand}; use sausage::{Host, world::World}; use serde_json::json; use std::{io::Write, path::PathBuf}; +mod test_runner; #[derive(Parser)] #[command(version)] struct Cli { @@ -16,8 +17,18 @@ struct Cli { } #[derive(Subcommand)] enum Cmd { - Init { dir: PathBuf }, - Run { dir: PathBuf }, + Init { + dir: PathBuf, + }, + Run { + dir: PathBuf, + }, + /// Run a WASI libtest component in isolated instances. + TestRunner { + wasm: PathBuf, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } fn setting(var: &str, question: &str, default: Option<&str>) -> Result { if let Ok(v) = std::env::var(var) @@ -41,6 +52,11 @@ fn setting(var: &str, question: &str, default: Option<&str>) -> Result { async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { + Cmd::TestRunner { wasm, args } => { + if !test_runner::run(&wasm, args).await? { + std::process::exit(1); + } + } Cmd::Init { dir } => { let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?; if api != "openai-responses" && api != "anthropic-messages" { @@ -65,10 +81,12 @@ async fn main() -> Result<()> { None => Host::start(&dir).await?, }; let mut notifications = host.subscribe(); - let notification_task = tokio::spawn(async move { - while let Ok(notification) = notifications.recv().await { - println!("{notification}"); - } + let notification_task = cli.headless.then(|| { + tokio::spawn(async move { + while let Ok(notification) = notifications.recv().await { + println!("{notification}"); + } + }) }); let console = sausage::console::run_input(host.clone()); let exit = tokio::select! { @@ -82,7 +100,9 @@ async fn main() -> Result<()> { }; let exit = exit || host.restart_requested(); host.stop().await; - notification_task.abort(); + if let Some(task) = notification_task { + task.abort(); + } if exit { std::process::exit(75); } diff --git a/cli/src/routing.rs b/cli/src/routing.rs index 16929d1..e36c3ab 100644 --- a/cli/src/routing.rs +++ b/cli/src/routing.rs @@ -220,6 +220,30 @@ impl Host { return; } if let Some(method) = v["method"].as_str() { + if method == "core.notify" { + // Deliver progress before a following response removes its destination. + let params = v.get("params").cloned().unwrap_or(json!({})); + let result = if !params.is_object() { + Err(error(-32602, "params must be an object")) + } else if let Some(name) = params["name"].as_str() { + self.notify( + pid, + name, + params.get("params").cloned().unwrap_or(json!({})), + ); + Ok(Value::Null) + } else { + Err(error(-32602, "name must be a string")) + }; + if let Some(id) = v.get("id") { + let response = match result { + Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}), + Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}), + }; + self.send(pid, response); + } + return; + } let h = self.clone(); let caller = pid.to_string(); let method = method.to_string(); diff --git a/cli/src/runtime.rs b/cli/src/runtime.rs index c3a7f2b..9de556e 100644 --- a/cli/src/runtime.rs +++ b/cli/src/runtime.rs @@ -243,12 +243,23 @@ impl Runtime { self: &Arc, component: &Component, cfg: InstanceConfig, + ) -> Result { + self.start_with_args(component, cfg, &[]).await + } + + /// Start a WASI task with explicit CLI arguments, including its program name. + pub async fn start_with_args( + self: &Arc, + component: &Component, + cfg: InstanceConfig, + args: &[String], ) -> Result { let (host_in, guest_in) = tokio::io::duplex(64 * 1024); let (guest_out, host_out) = tokio::io::duplex(64 * 1024); let (guest_err, host_err) = tokio::io::duplex(64 * 1024); let mut b = WasiCtx::builder(); + b.args(args); if cfg.network { b.inherit_network() .allow_tcp(true) diff --git a/cli/src/test_runner.rs b/cli/src/test_runner.rs new file mode 100644 index 0000000..8e7fd70 --- /dev/null +++ b/cli/src/test_runner.rs @@ -0,0 +1,237 @@ +//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport. +use anyhow::{Result, bail}; +use sausage::{ + Host, + runtime::{Exit, InstanceConfig, Runtime}, + world::World, +}; +use serde_json::{Value, json}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::io::AsyncWriteExt; +use wasmtime::component::Component; + +/// A temporary world remains alive through the end of its WASI store. +struct TestWorld(PathBuf); +impl Drop for TestWorld { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +struct Run { + exit: Exit, + output: String, +} +/// Runs one instance and separates JSON-RPC requests from ordinary test output. +async fn instance( + runtime: Arc, + component: Component, + args: Vec, + package: PathBuf, +) -> Result { + let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7())); + World::create(&path)?; + let world = TestWorld(path); + let host = Host::start(&world.0).await?; + let tracker = sausage_vfs::Process::default(); + let mut filesystem = host.filesystem().await?; + filesystem.mount( + package.to_str().unwrap(), + Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?), + false, + ); + let mut env: Vec<_> = std::env::vars().filter(|(key, _)| key != "PWD").collect(); + env.extend([ + ("PWD".into(), package.display().to_string()), + ("SAUSAGE_PID".into(), "test".into()), + ("SAUSAGE_PLUGIN".into(), "test".into()), + ("SAUSAGE_PARENT".into(), "core".into()), + ]); + let mut guest = runtime + .start_with_args( + &component, + InstanceConfig { + filesystem, + store_ended: tokio::sync::watch::channel(true).0, + network: true, + env, + }, + &args, + ) + .await?; + let mut stdin = guest.stdin.take().unwrap(); + let mut stdout = std::mem::replace(&mut guest.stdout, tokio::sync::mpsc::channel(1).1); + let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1); + let mut exit = None; + let mut output = String::new(); + let wait = guest.wait(); + tokio::pin!(wait); + let mut stdout_open = true; + let mut stderr_open = true; + let mut handlers = tokio::task::JoinSet::new(); + while exit.is_none() || stdout_open || stderr_open { + tokio::select! { + result=&mut wait, if exit.is_none()=>exit=Some(result), + line=stdout.recv(), if stdout_open=>match line { + Some(line)=> { + // Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line. + if let Ok(request)=serde_json::from_str::(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() { + let host = host.clone(); + handlers.spawn(async move { + let method=request["method"].as_str().unwrap(); + let params=request.get("params").cloned().unwrap_or(json!({})); + let result=match method { + "core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await, + "core.notify"=>Ok(Value::Null), + _=>Err(json!({"code":-32601,"message":"a test has no other plugins"})), + }; + request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})}) + }); + } else {output.push_str(&line);output.push('\n');} + },None=>stdout_open=false, + }, + line=stderr.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false}, + response=handlers.join_next(), if !handlers.is_empty()=> { + if let Some(response) = response.transpose()?.flatten() { + stdin.write_all(format!("{response}\n").as_bytes()).await?; + stdin.flush().await?; + } + }, + } + } + handlers.abort_all(); + tracker.idle().await; + host.stop().await; + Ok(Run { + exit: exit.unwrap(), + output, + }) +} +/// Extracts libtest names from its stable list format. +fn names(output: &str) -> Vec { + output + .lines() + .filter_map(|line| line.strip_suffix(": test").map(str::to_owned)) + .collect() +} +/// Lists tests, selects libtest filters, and runs each selected test on the core pool. +pub async fn run(wasm: &Path, args: Vec) -> Result { + let package = std::env::current_dir()?; + let cache = std::env::var_os("XDG_CACHE_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache") + }) + .join("sausage"); + let compile_path = wasm.to_path_buf(); + let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> { + let runtime = Runtime::new(&cache)?; + let component = runtime.compile(&std::fs::read(compile_path)?)?; + Ok((runtime, component)) + }) + .await??; + let mut listing_args = vec![wasm.display().to_string(), "--list".into()]; + listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned()); + let listing = instance( + runtime.clone(), + component.clone(), + listing_args.clone(), + package.clone(), + ) + .await?; + if !matches!(listing.exit, Exit::Clean) { + bail!( + "cannot list WASI tests: {}\n{}", + listing.exit, + listing.output + ); + } + if args.iter().any(|arg| arg == "--list") { + print!("{}", listing.output); + return Ok(true); + } + let selected = names(&listing.output); + listing_args.push("--ignored".into()); + let ignored = if args + .iter() + .any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored")) + { + Vec::new() + } else { + names( + &instance( + runtime.clone(), + component.clone(), + listing_args, + package.clone(), + ) + .await? + .output, + ) + }; + println!("running {} tests", selected.len()); + let cores = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1); + let semaphore = Arc::new(tokio::sync::Semaphore::new(cores)); + let mut tasks = tokio::task::JoinSet::new(); + let mut skipped = 0; + for name in selected { + if ignored.contains(&name) { + println!("test {name} ... ignored"); + skipped += 1; + continue; + } + let runtime = runtime.clone(); + let component = component.clone(); + let package = package.clone(); + let semaphore = semaphore.clone(); + let arguments = vec![ + wasm.display().to_string(), + "--exact".into(), + name.clone(), + "--nocapture".into(), + "--test-threads=1".into(), + "--include-ignored".into(), + ]; + tasks.spawn(async move { + let _permit = semaphore.acquire_owned().await?; + let result = instance(runtime, component, arguments, package).await; + Ok::<_, anyhow::Error>((name, result)) + }); + } + let mut passed = 0; + let mut failed = 0; + while let Some(result) = tasks.join_next().await { + let (name, result) = result??; + let success = result + .as_ref() + .is_ok_and(|run| matches!(run.exit, Exit::Clean)); + if success { + passed += 1; + } else { + failed += 1; + } + println!("test {name} ... {}", if success { "ok" } else { "FAILED" }); + match result { + Ok(run) => { + for line in run + .output + .lines() + .filter(|line| !line.starts_with("test result:")) + { + // Indentation keeps ordinary test output separate from our libtest status and totals. + println!(" {line}"); + } + } + Err(error) => println!("{error:#}"), + } + } + println!( + "\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out", + if failed == 0 { "ok" } else { "FAILED" } + ); + Ok(failed == 0) +} diff --git a/cli/tests/cargo.rs b/cli/tests/cargo.rs new file mode 100644 index 0000000..7463256 --- /dev/null +++ b/cli/tests/cargo.rs @@ -0,0 +1,349 @@ +//! 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; +} diff --git a/cli/tests/console.rs b/cli/tests/console.rs new file mode 100644 index 0000000..6379698 --- /dev/null +++ b/cli/tests/console.rs @@ -0,0 +1,255 @@ +//! Console command contracts against a temporary world and the raw probe component. +use sausage::{ + Host, + console::{execute_line, notification_text}, + world::World, +}; +use serde_json::{Value, json}; +use std::{path::PathBuf, sync::OnceLock}; + +fn fixture() -> PathBuf { + static WASM: OnceLock = OnceLock::new(); + WASM.get_or_init(|| { + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_owned(); + let target = root.join("target/vfs/console-guests"); + let status = std::process::Command::new("cargo") + .args([ + "build", + "--offline", + "--target", + "wasm32-wasip2", + "--manifest-path", + ]) + .arg(root.join("cli/tests/plugins/probe/Cargo.toml")) + .arg("--target-dir") + .arg(&target) + .status() + .unwrap(); + assert!(status.success()); + target.join("wasm32-wasip2/debug/probe.wasm") + }) + .clone() +} +struct TestWorld(PathBuf); +impl TestWorld { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7())); + let world = World::create(&root).unwrap(); + for plugin in ["probe", "agent"] { + std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap(); + std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap(); + } + std::fs::create_dir_all(world.state_dir("probe")).unwrap(); + std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap(); + std::fs::copy(fixture(), root.join("candidate.wasm")).unwrap(); + Self(root) + } + async fn start(&self) -> Host { + let cache = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("target/vfs/console-cache"); + Host::start_with_cache(&self.0, &cache).await.unwrap() + } +} +impl Drop for TestWorld { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +async fn command(host: &Host, line: &str) -> String { + tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line)) + .await + .unwrap() + .unwrap() +} + +/// Explicit calls return indented JSON data, and RPC errors remain visible. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rpc_call() { + let world = TestWorld::new(); + let host = world.start().await; + let result = command(&host, "probe.echo {\"answer\":42}").await; + assert!(result.contains("\n \"answer\": 42\n")); + assert!(command(&host, "absent.echo {}").await.contains("error:")); + assert!(execute_line(&host, "probe.echo not-json").await.is_err()); + host.stop().await; +} +/// Plain text calls agent.chat and its interim notification uses dim styling. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn chat() { + let world = TestWorld::new(); + let host = world.start().await; + let mut notifications = host.subscribe(); + assert_eq!(command(&host, "hello agent").await, "hello agent"); + let value = notifications.recv().await.unwrap(); + assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m"); + assert_eq!( + command(&host, "Hello. Another sentence.").await, + "Hello. Another sentence." + ); + host.stop().await; +} +/// Folder listings include the live mounts and accept an explicit folder. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ls() { + let world = TestWorld::new(); + let host = world.start().await; + let result = command(&host, "ls").await; + for name in ["proc/", "run/", "self/"] { + assert!(result.contains(name)); + } + assert!(command(&host, "ls /proc/core").await.contains("plugins/")); + host.stop().await; +} +/// Cat reads multiple chunks and finite generated snapshots through the VFS. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cat() { + let world = TestWorld::new(); + let host = world.start().await; + let text = "x".repeat(150_000); + std::fs::write(world.0.join("large"), &text).unwrap(); + assert_eq!(command(&host, "cat /large").await, text); + host.call("probe.write", json!({"path":"/run/shared","text":"memory"})) + .await + .unwrap(); + assert_eq!(command(&host, "cat /run/shared").await, "memory"); + assert!( + command(&host, "cat /proc/core/PROTOCOL.md") + .await + .contains("filesystem") + ); + assert!(execute_line(&host, "cat /missing").await.is_err()); + host.stop().await; +} +/// Method summaries include parameter types, result types, and documentation. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn methods() { + let world = TestWorld::new(); + let host = world.start().await; + let result = command(&host, "methods probe").await; + for text in [ + "probe.echo(text: string) -> string", + "Echo supplied text.", + "The input text.", + ] { + assert!(result.contains(text), "{result}"); + } + host.stop().await; +} +/// Ps includes every requested column and the live process metadata. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ps() { + let world = TestWorld::new(); + let host = world.start().await; + let pid = host.call("probe.serve", json!({})).await.unwrap(); + let text = command(&host, "ps").await; + assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE")); + assert!(text.contains(pid.as_str().unwrap())); + assert!(text.contains("probe serve console")); + host.stop().await; +} +/// Log supports plugin filters, process filters, and a row count. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn log() { + let world = TestWorld::new(); + std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap(); + std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap(); + let host = world.start().await; + host.call("data-logger.echo", json!({})).await.unwrap(); + let filtered: Value = + serde_json::from_str(&command(&host, "log data-logger -n 1").await).unwrap(); + assert_eq!(filtered[0]["plugin"], "data-logger"); + let pid = host.call("probe.serve", json!({})).await.unwrap(); + for line in [ + "log".to_owned(), + "log probe -n 1".to_owned(), + format!("log -n 1 {}", pid.as_str().unwrap()), + ] { + let entries: Value = serde_json::from_str(&command(&host, &line).await).unwrap(); + assert!(!entries.as_array().unwrap().is_empty()); + if line != "log" { + assert_eq!(entries.as_array().unwrap().len(), 1); + assert_eq!(entries[0]["plugin"], "probe"); + } + } + assert!(execute_line(&host, "log -n").await.is_err()); + host.stop().await; +} +/// Activate installs a candidate through core.activate. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn activate() { + let world = TestWorld::new(); + let host = world.start().await; + let result = command(&host, "activate probe candidate.wasm").await; + assert!(!result.starts_with("error:"), "{result}"); + assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists()); + host.stop().await; +} +/// Revert restores the previous component through core.revert. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revert() { + let world = TestWorld::new(); + let host = world.start().await; + command(&host, "activate probe candidate.wasm").await; + let result = command(&host, "revert probe").await; + assert!(!result.starts_with("error:"), "{result}"); + host.stop().await; +} +/// Stop closes a live process gracefully. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stop() { + let world = TestWorld::new(); + let host = world.start().await; + let pid = host.call("probe.serve", json!({})).await.unwrap(); + command(&host, &format!("stop {}", pid.as_str().unwrap())).await; + assert!(!host.processes().contains_key(pid.as_str().unwrap())); + host.stop().await; +} +/// Kill ends a live process immediately. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn kill() { + let world = TestWorld::new(); + let host = world.start().await; + let pid = host.call("probe.serve", json!({})).await.unwrap(); + command(&host, &format!("kill {}", pid.as_str().unwrap())).await; + assert!(!host.processes().contains_key(pid.as_str().unwrap())); + host.stop().await; +} +/// Help lists all console commands. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn help() { + let world = TestWorld::new(); + let host = world.start().await; + let text = command(&host, "help").await; + for name in [ + "agent", + "plugin.method", + "ls", + "cat", + "methods", + "ps", + "log", + "activate", + "revert", + "stop", + "kill", + "help", + "quit", + ] { + assert!(text.contains(name)); + } + host.stop().await; +} +/// Quit produces a final message, and blank input does nothing. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn quit() { + let world = TestWorld::new(); + let host = world.start().await; + assert_eq!(command(&host, "quit").await, "Goodbye."); + assert_eq!(command(&host, " ").await, ""); + host.stop().await; +} diff --git a/cli/tests/core.rs b/cli/tests/core.rs index 62eb581..f951ccd 100644 --- a/cli/tests/core.rs +++ b/cli/tests/core.rs @@ -243,6 +243,34 @@ async fn progress_notifications_reach_the_caller() { h.stop().await; } +/// PROTOCOL 3.5: every stdout notification reaches the caller before the following result. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn stdout_notifications_precede_the_result() { + let w = TestWorld::new(); + let h = w.start().await; + let mut notifications = h.subscribe(); + for _ in 0..10 { + assert_eq!(call(&h, "probe.burst", json!({"count":64})).await, 64); + let mut call_id = None; + for index in 0..64 { + // A delayed notification fails here, even if it arrives later. + let notification = notifications.try_recv().unwrap(); + assert_eq!(notification["params"]["name"], "burst"); + assert_eq!(notification["params"]["params"]["index"], index); + if let Some(id) = &call_id { + assert_eq!(¬ification["params"]["call"], id); + } else { + call_id = Some(notification["params"]["call"].clone()); + } + } + assert!(matches!( + notifications.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + } + h.stop().await; +} + /// PROTOCOL 3.1: non message stdout warns once without its content. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn non_message_stdout_warns_once_without_its_content() { diff --git a/cli/tests/crates/cargo-c/Cargo.toml b/cli/tests/crates/cargo-c/Cargo.toml index e5f9906..aff3e8d 100644 --- a/cli/tests/crates/cargo-c/Cargo.toml +++ b/cli/tests/crates/cargo-c/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -sausage-actor = "0.1" +sausage-plugin = "0.1" [build-dependencies] cc = "1" diff --git a/cli/tests/crates/cargo-c/src/main.rs b/cli/tests/crates/cargo-c/src/main.rs index 43a1d30..6d830cb 100644 --- a/cli/tests/crates/cargo-c/src/main.rs +++ b/cli/tests/crates/cargo-c/src/main.rs @@ -1,25 +1,25 @@ -//! Adds two numbers with a C function: `{"type": "add", "a", "b"}` -> `{"type": "sum", "value"}`. -use sausage_actor::{json, messages, reply}; - +//! SDK plugin linked to a WASI C function. unsafe extern "C" { fn c_add(a: i32, b: i32) -> i32; } - fn add(a: i32, b: i32) -> i32 { unsafe { c_add(a, b) } } - -fn main() { - for message in messages() { - let (a, b) = (message.body["a"].as_i64().unwrap_or(0), message.body["b"].as_i64().unwrap_or(0)); - let _ = reply(&message, json!({"type": "sum", "value": add(a as i32, b as i32)})); +struct Plugin; +#[sausage_plugin::rpc] +impl Plugin { + /// Add two fixed numbers through C. + fn sum(&self) -> sausage_plugin::Result { + Ok(add(2, 3)) } } - +fn main() { + sausage_plugin::run(Plugin); +} #[cfg(test)] mod tests { #[test] - fn c_code_runs_on_the_host_target() { + fn c_code_runs_under_wasi() { assert_eq!(super::add(2, 3), 5); } } diff --git a/cli/tests/crates/cargo-hang/Cargo.toml b/cli/tests/crates/cargo-hang/Cargo.toml new file mode 100644 index 0000000..215006f --- /dev/null +++ b/cli/tests/crates/cargo-hang/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "cargo-hang" +version = "0.1.0" +edition = "2024" + +[workspace] diff --git a/cli/tests/crates/cargo-hang/build.rs b/cli/tests/crates/cargo-hang/build.rs new file mode 100644 index 0000000..4c77e75 --- /dev/null +++ b/cli/tests/crates/cargo-hang/build.rs @@ -0,0 +1,7 @@ +//! A build script descendant that stays alive until the native call ends. +fn main() { + std::process::Command::new("sh") + .args(["-c", "echo $$ > child.pid; exec sleep 1000"]) + .status() + .unwrap(); +} diff --git a/cli/tests/crates/cargo-hang/src/main.rs b/cli/tests/crates/cargo-hang/src/main.rs new file mode 100644 index 0000000..f328e4d --- /dev/null +++ b/cli/tests/crates/cargo-hang/src/main.rs @@ -0,0 +1 @@ +fn main() {} diff --git a/cli/tests/crates/cargo-test-fail/src/main.rs b/cli/tests/crates/cargo-test-fail/src/main.rs index 65dfa17..3ce03e9 100644 --- a/cli/tests/crates/cargo-test-fail/src/main.rs +++ b/cli/tests/crates/cargo-test-fail/src/main.rs @@ -1,9 +1,13 @@ +//! A panic must not prevent the runner from executing another test. fn main() {} - #[cfg(test)] mod tests { #[test] - fn deliberate_unit_test_failure() { - assert_eq!(2 + 2, 5); + fn a_panics() { + panic!("deliberate WASI panic"); + } + #[test] + fn z_survives() { + assert_eq!(2 + 2, 4); } } diff --git a/cli/tests/crates/cargo-valid/Cargo.toml b/cli/tests/crates/cargo-valid/Cargo.toml index 8355632..9dfbb1b 100644 --- a/cli/tests/crates/cargo-valid/Cargo.toml +++ b/cli/tests/crates/cargo-valid/Cargo.toml @@ -3,4 +3,7 @@ name = "cargo-valid" version = "0.1.0" edition = "2024" +[dependencies] +sausage-plugin = "0.1" + [workspace] diff --git a/cli/tests/crates/cargo-valid/fixture.txt b/cli/tests/crates/cargo-valid/fixture.txt new file mode 100644 index 0000000..ee8c1ee --- /dev/null +++ b/cli/tests/crates/cargo-valid/fixture.txt @@ -0,0 +1 @@ +fixture diff --git a/cli/tests/crates/cargo-valid/src/main.rs b/cli/tests/crates/cargo-valid/src/main.rs index 0e259da..28ec5a8 100644 --- a/cli/tests/crates/cargo-valid/src/main.rs +++ b/cli/tests/crates/cargo-valid/src/main.rs @@ -1,9 +1,74 @@ -fn main() {} - +//! SDK plugin and tests for the native build pipeline. +struct Plugin; +#[sausage_plugin::rpc] +impl Plugin { + /// Return a greeting. + fn hello(&self) -> sausage_plugin::Result { + Ok("hello".into()) + } +} +fn main() { + sausage_plugin::run(Plugin); +} #[cfg(test)] mod tests { #[test] - fn unit_tests_run_on_the_host_target() { + fn arithmetic() { assert_eq!(2 + 2, 4); } + #[test] + fn fixture_at_manifest_path() { + assert_eq!( + std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(), + "fixture\n" + ); + } + #[test] + fn current_directory() { + println!( + "WASI_CURRENT_DIR={}", + std::env::current_dir().unwrap().display() + ); + } + #[sausage_plugin::test] + async fn sdk_lock() { + let guard = sausage_plugin::lock("/state/lock").await.unwrap(); + assert!(guard.held()); + let mut guard = Some(guard); + let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock( + "/state/lock", + ))); + let acquired = std::future::poll_fn(|context| { + let result = std::future::Future::poll(waiting.as_mut(), context); + if result.is_pending() { + drop(guard.take()); + } + result + }) + .await + .unwrap(); + assert!(acquired.held()); + let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({})) + .await + .unwrap(); + assert!(logs.is_array()); + sausage_plugin::call( + "core.notify", + sausage_plugin::serde_json::json!({"name":"progress","params":{}}), + ) + .await + .unwrap(); + } + #[sausage_plugin::test] + async fn other_plugin_fails() { + let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({})) + .await + .unwrap_err(); + assert!(error.to_string().contains("a test has no other plugins")); + } + #[test] + #[ignore] + fn ignored_case() { + assert_eq!(1, 1); + } } diff --git a/cli/tests/plugins/probe/src/main.rs b/cli/tests/plugins/probe/src/main.rs index 581f12e..85d8389 100644 --- a/cli/tests/plugins/probe/src/main.rs +++ b/cli/tests/plugins/probe/src/main.rs @@ -14,6 +14,13 @@ fn receive(lines: &mut Lines) -> Value { fn echo(params: &Value) -> Value { params.clone() } +fn chat(params: &Value) -> Value { + send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{ + "name":"text", "params":{"text":"interim"} + }})); + std::thread::sleep(std::time::Duration::from_millis(30)); + json!({"text":params["text"]}) +} fn discover(params: &Value) -> Value { if let Ok(bytes) = std::fs::read("/state/probe/discover.json") { serde_json::from_slice(&bytes).unwrap() @@ -105,6 +112,25 @@ fn blank() -> Value { println!("\n \t\n"); json!({}) } +fn burst(request: &Value) { + let count = request["params"]["count"].as_u64().unwrap(); + let mut output = String::new(); + for index in 0..count { + let notification = json!({ + "jsonrpc": "2.0", + "method": "core.notify", + "params": {"name": "burst", "params": {"index": index}} + }); + output.push_str(¬ification.to_string()); + output.push('\n'); + } + let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count}); + output.push_str(&response.to_string()); + output.push('\n'); + let mut stdout = io::stdout().lock(); + stdout.write_all(output.as_bytes()).unwrap(); + stdout.flush().unwrap(); +} fn notify() -> Value { send(json!({ "jsonrpc": "2.0", @@ -281,6 +307,7 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option { } "locked_write" => locked_write(lines), "echo" => echo(params), + "chat" => chat(params), "rpc.discover" => discover(params), "env" => environment(), "multicall" => multicall(lines), @@ -304,6 +331,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option { "blank" => blank(), "invalid_utf8" => invalid_utf8(), "notify" => notify(), + "burst" => { + burst(request); + return None; + } "serve" => { serve(request, lines); return None; diff --git a/interface/src/lib.rs b/interface/src/lib.rs index 97d2aa1..1eb2d3c 100644 --- a/interface/src/lib.rs +++ b/interface/src/lib.rs @@ -4,7 +4,7 @@ pub mod compat; pub mod document; pub use document::Document; -#[cfg(feature="codegen")] +#[cfg(feature = "codegen")] mod client; -#[cfg(feature="codegen")] +#[cfg(feature = "codegen")] pub use client::client_rs;