The timing rules are in two tests (upgrade_and_discovery_deadlines, stop_deadlines). All other tests use a grace period of 0 or core.stop, poll with deadlines instead of sleeping, and use the persistent cache in CARGO_TARGET_TMPDIR. The 128-cycle singleton test is an ignored stress test that nix flake check runs; the normal run has a 4-cycle sample. Warm, the activation suite takes 12 s (was 47 s), and cargo test --workspace takes 65 s.
368 lines
13 KiB
Rust
368 lines
13 KiB
Rust
//! Exercise SDK plugins through the real host, without a fake core.
|
|
use sausage::{Host, world::World};
|
|
use serde_json::{Value, json};
|
|
use std::{path::PathBuf, sync::OnceLock, time::Duration};
|
|
|
|
fn fixtures() -> &'static PathBuf {
|
|
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
|
TARGET.get_or_init(|| {
|
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.to_owned();
|
|
let target = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
|
|
for package in [
|
|
"examples/notes",
|
|
"examples/consumer",
|
|
"examples/www",
|
|
"examples/secrets",
|
|
"sdk/tests/plugins/probe",
|
|
"sdk/tests/plugins/consumer",
|
|
] {
|
|
assert!(
|
|
std::process::Command::new("cargo")
|
|
.args([
|
|
"build",
|
|
"--offline",
|
|
"--target",
|
|
"wasm32-wasip2",
|
|
"--manifest-path"
|
|
])
|
|
.arg(root.join(package).join("Cargo.toml"))
|
|
.env("CARGO_TARGET_DIR", &target)
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
);
|
|
}
|
|
target.join("wasm32-wasip2/debug")
|
|
})
|
|
}
|
|
struct TestWorld(PathBuf);
|
|
impl TestWorld {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7()));
|
|
let world = World::create(&path).unwrap();
|
|
for (plugin, binary) in [
|
|
("notes", "notes"),
|
|
("consumer", "consumer"),
|
|
("www", "www"),
|
|
("secrets", "secrets"),
|
|
("probe", "sdk-probe"),
|
|
("typed", "sdk-consumer"),
|
|
] {
|
|
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
|
std::fs::copy(
|
|
fixtures().join(format!("{binary}.wasm")),
|
|
world.plugin_dir(plugin).join("plugin.wasm"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
Self(path)
|
|
}
|
|
async fn start(&self) -> Host {
|
|
Host::start_with_cache(
|
|
&self.0,
|
|
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
|
)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
}
|
|
impl Drop for TestWorld {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
|
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
|
|
.await
|
|
.unwrap()
|
|
.unwrap()
|
|
}
|
|
|
|
/// Concurrent read-modify-write calls retain every note under the SDK lock.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn notes_lock_preserves_parallel_additions() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert_eq!(
|
|
call(&host, "notes.add", json!({"text":"first"})).await["count"],
|
|
1
|
|
);
|
|
let calls = (0..12)
|
|
.map(|n| {
|
|
let h = host.clone();
|
|
tokio::spawn(
|
|
async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await },
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for task in calls {
|
|
task.await.unwrap();
|
|
}
|
|
let notes = call(&host, "notes.list", json!({})).await;
|
|
assert_eq!(notes.as_array().unwrap().len(), 13);
|
|
assert_eq!(notes[0], "first");
|
|
for n in 0..12 {
|
|
assert!(
|
|
notes
|
|
.as_array()
|
|
.unwrap()
|
|
.contains(&json!(format!("note-{n}")))
|
|
);
|
|
}
|
|
host.stop().await;
|
|
}
|
|
/// The example consumer invokes notes through its generated import.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn generated_notes_import() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert_eq!(
|
|
call(&host, "consumer.note", json!({"text":"typed"})).await,
|
|
1
|
|
);
|
|
assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"]));
|
|
host.stop().await;
|
|
}
|
|
/// Discovery retains the complete document and satisfies the consumer contract.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovery_is_compatible() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let actual = call(&host, "notes.rpc.discover", json!({})).await;
|
|
let consumer = call(&host, "consumer.rpc.discover", json!({})).await;
|
|
let expected: Value =
|
|
serde_json::from_str(include_str!("../../examples/consumer/imports/notes.json")).unwrap();
|
|
assert_eq!(actual, expected);
|
|
assert_eq!(actual["openrpc"], "1.2.6");
|
|
assert!(actual["components"]["schemas"]["AddParams"].is_object());
|
|
assert!(
|
|
actual["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|m| m["name"] == "list")
|
|
.unwrap()
|
|
.get("x-sausage-params")
|
|
.is_none()
|
|
);
|
|
assert!(actual["components"]["schemas"].get("Empty").is_none());
|
|
assert!(
|
|
sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual)
|
|
.is_empty()
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// Detached SDK calls receive progress and forward it through core.notify.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn detached_call_notifications() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let mut events = host.subscribe();
|
|
assert_eq!(call(&host, "probe.relay", json!({})).await, 42);
|
|
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
|
.await
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(event["params"]["name"], "step");
|
|
host.stop().await;
|
|
}
|
|
/// Detached typed calls complete and detached services survive their original caller.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn detached_service_survives_parent() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert_eq!(call(&host, "typed.run", json!({})).await, 5);
|
|
let child = call(&host, "probe.launch", json!({})).await;
|
|
tokio::time::timeout(Duration::from_secs(5), async {
|
|
while host.processes().values().any(|p| p.method == "launch") {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(host.processes().contains_key(child.as_str().unwrap()));
|
|
host.stop().await;
|
|
}
|
|
/// A core.stop request closes stdin and executes the SDK stop hook.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn stop_runs_hook() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let pid = call(&host, "probe.service", json!({})).await;
|
|
call(&host, "core.stop", json!({"pid":pid})).await;
|
|
assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes");
|
|
host.stop().await;
|
|
}
|
|
/// SDK framing carries payloads above 64 KiB in both directions.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn large_messages() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let text = "z".repeat(192 * 1024);
|
|
assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text);
|
|
host.stop().await;
|
|
}
|
|
/// Normal SDK stdout never produces the discarded-output journal warning.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn no_discarded_stdout() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
call(&host, "probe.echo", json!({"text":"framing"})).await;
|
|
host.stop().await;
|
|
assert_eq!(
|
|
call(&host, "core.log", json!({"grep":"discarded stdout"})).await,
|
|
json!([])
|
|
);
|
|
}
|
|
/// Two SDK processes exchange endpoint requests and progress at a world path.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn world_endpoint() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
call(&host, "probe.service", json!({})).await;
|
|
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
|
host.stop().await;
|
|
}
|
|
/// The HTTP example exposes its self endpoint through the process namespace.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn www_control_through_proc() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert_eq!(
|
|
call(&host, "consumer.demo", json!({"text":"web","port":0})).await,
|
|
0
|
|
);
|
|
host.stop().await;
|
|
}
|
|
/// The vault example publishes a fixed endpoint in the shared run namespace.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn secrets_fixed_endpoint() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
call(&host, "secrets.serve", json!({})).await;
|
|
assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello");
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 3.6: a background task retains its SDK process until the task completes.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn background_task_retains_process() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let pid = call(&host, "probe.background_hold", json!({})).await;
|
|
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
|
assert_eq!(call(&host, "notes.list", json!({})).await, json!([]));
|
|
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
|
std::fs::write(world.0.join("release-background"), "release").unwrap();
|
|
tokio::time::timeout(Duration::from_secs(10), async {
|
|
while host.processes().contains_key(pid.as_str().unwrap()) {
|
|
tokio::task::yield_now().await;
|
|
}
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
std::fs::read_to_string(world.0.join("background-ended")).unwrap(),
|
|
"done"
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 5: stale endpoint identities and interfaces fail through genuine SDK processes.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn endpoint_handshake_rejects_mismatch() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let pid = call(&host, "probe.service", json!({})).await;
|
|
let path = world.0.join("state/control");
|
|
let original = std::fs::read_to_string(&path).unwrap();
|
|
let words: Vec<_> = original.split_whitespace().collect();
|
|
for changed in [
|
|
format!("{} wrong-owner {}\n", words[0], words[2]),
|
|
format!("{} {} wrong-interface\n", words[0], words[1]),
|
|
] {
|
|
std::fs::write(&path, changed).unwrap();
|
|
let error = host
|
|
.call("typed.world_endpoint", json!({}))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(error["code"], -32000);
|
|
assert!(error["message"].as_str().unwrap().contains("mismatch"));
|
|
assert!(host.processes().contains_key(pid.as_str().unwrap()));
|
|
}
|
|
std::fs::write(path, original).unwrap();
|
|
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 10.1 and 5: discovery through core retains params, endpoints, imports, and grace.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn discovery_extensions_through_core() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
let provider = call(&host, "probe.rpc.discover", json!({})).await;
|
|
assert_eq!(provider["openrpc"], "1.2.6");
|
|
assert_eq!(provider["x-sausage-grace"], 12);
|
|
assert!(
|
|
provider["x-sausage-endpoints"]["control"]["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|m| m["name"] == "stats")
|
|
);
|
|
let add = provider["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|m| m["name"] == "add")
|
|
.unwrap();
|
|
assert_eq!(add["paramStructure"], "by-name");
|
|
let reference = add["x-sausage-params"]["$ref"].as_str().unwrap();
|
|
assert_eq!(
|
|
provider.pointer(reference.trim_start_matches('#')).unwrap()["type"],
|
|
"object"
|
|
);
|
|
let consumer = call(&host, "typed.rpc.discover", json!({})).await;
|
|
assert!(
|
|
consumer["x-sausage-imports"]["probe"]["methods"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|m| m["name"] == "add")
|
|
);
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 3.2: rpc.discover is SDK control traffic and endpoint control is not a main method.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn reserved_rpc_control_names() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert!(call(&host, "probe.rpc.discover", json!({})).await["methods"].is_array());
|
|
for method in ["probe.rpc.endpoint", "probe.rpc.unknown"] {
|
|
assert_eq!(
|
|
host.call(method, json!({})).await.unwrap_err()["code"],
|
|
-32601
|
|
);
|
|
}
|
|
host.stop().await;
|
|
}
|
|
|
|
/// PROTOCOL 3.1: the SDK's leading newline isolates an unfinished diagnostic from its response.
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn leading_newline_protects_sdk_response() {
|
|
let world = TestWorld::new();
|
|
let host = world.start().await;
|
|
assert_eq!(
|
|
call(&host, "probe.framed_output", json!({})).await,
|
|
"intact"
|
|
);
|
|
host.stop().await;
|
|
let warnings = call(&host, "core.log", json!({"grep":"discarded stdout"})).await;
|
|
assert_eq!(warnings.as_array().unwrap().len(), 1);
|
|
assert!(!warnings.to_string().contains("partial diagnostic"));
|
|
}
|