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/<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.
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
@@ -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<PathBuf> = 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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-actor = "0.1"
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
@@ -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<i32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "cargo-hang"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,7 @@ name = "cargo-valid"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
fixture
|
||||
@@ -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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
}
|
||||
"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<Value> {
|
||||
"blank" => blank(),
|
||||
"invalid_utf8" => invalid_utf8(),
|
||||
"notify" => notify(),
|
||||
"burst" => {
|
||||
burst(request);
|
||||
return None;
|
||||
}
|
||||
"serve" => {
|
||||
serve(request, lines);
|
||||
return None;
|
||||
|
||||
Reference in New Issue
Block a user