console: one sequential loop; host tests in one binary
The console reads a line, runs it to the end, and prints the result, with and without a terminal. Notifications print while a command runs. Ctrl-C during a command stops only the wait. 314 lines (was 480). All host integration tests are modules of cli/tests/host/, so they run in one parallel pool, and the probes build once. Warm, the 184 host tests take 24 s (was about 45 s as seven binaries in sequence), and cargo test --workspace takes 45 s.
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
//! Activation, graph, and upgrade integration tests with raw WASI guests.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
/// Builds genuine guest versions in this executor's target folder.
|
||||
fn fixture(version: usize) -> PathBuf {
|
||||
static FILES: OnceLock<Vec<PathBuf>> = OnceLock::new();
|
||||
FILES.get_or_init(|| {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/activation-guests"));
|
||||
(1..=3)
|
||||
.map(|version| {
|
||||
let target = target.join(version.to_string());
|
||||
let status = std::process::Command::new("cargo")
|
||||
.env("FIXTURE_VERSION", version.to_string())
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/activation/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let copy = target.join(format!("v{version}.wasm"));
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/debug/activation-guest.wasm"),
|
||||
©,
|
||||
)
|
||||
.unwrap();
|
||||
copy
|
||||
})
|
||||
.collect()
|
||||
})[version - 1]
|
||||
.clone()
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
/// Creates an isolated world with two version documents per plugin.
|
||||
fn new() -> Self {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("sausage-activation-{}", uuid::Uuid::now_v7()));
|
||||
World::create(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
/// Installs version one and stages version two under the world state.
|
||||
fn plugin(&self, name: &str, old: Value, new: Value) {
|
||||
std::fs::create_dir_all(self.0.join(format!("plugins/{name}"))).unwrap();
|
||||
std::fs::create_dir_all(self.0.join(format!("state/{name}"))).unwrap();
|
||||
std::fs::copy(
|
||||
fixture(1),
|
||||
self.0.join(format!("plugins/{name}/plugin.wasm")),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture(2), self.0.join(format!("state/{name}/next.wasm"))).unwrap();
|
||||
std::fs::write(self.0.join(format!("state/{name}/1.json")), old.to_string()).unwrap();
|
||||
std::fs::write(self.0.join(format!("state/{name}/2.json")), new.to_string()).unwrap();
|
||||
}
|
||||
/// Starts a host with a persistent compile cache shared by the test suites.
|
||||
async fn start(&self) -> Host {
|
||||
Host::start_with_cache(
|
||||
&self.0,
|
||||
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);
|
||||
}
|
||||
}
|
||||
/// Builds a method-only document without an upgrade delay.
|
||||
fn document(name: &str) -> Value {
|
||||
json!({"methods":[{"name":name,"params":[],"result":{"schema":{"type":"string"}}}],"x-sausage-grace":0})
|
||||
}
|
||||
/// Adds an import of a provider's method list.
|
||||
fn consumer(provider: &str, expected: Value) -> Value {
|
||||
let mut document = document("consume");
|
||||
document["x-sausage-imports"] = json!({provider:expected});
|
||||
document
|
||||
}
|
||||
/// Performs a bounded test call without imposing a host execution limit.
|
||||
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
||||
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
/// Activates the staged candidate.
|
||||
async fn activate(host: &Host, name: &str) -> Result<Value, Value> {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(60),
|
||||
host.call(
|
||||
"core.activate",
|
||||
json!({"plugin":name,"wasm":format!("/state/{name}/next.wasm")}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
/// Waits for a lifecycle marker without a fixed delay.
|
||||
async fn marker(path: PathBuf, expected: &str) {
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
/// Reads the cached graph through the actual proc adapter.
|
||||
async fn graph(host: &Host) -> Value {
|
||||
call(host, "notes.read", json!({"path":"/proc/core/graph.json"})).await
|
||||
}
|
||||
|
||||
/// H1/H2: broken providers and stray files do not block repair, revert, or force.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_repairs_with_broken_entries() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
world.plugin("broken", document("bad"), document("bad"));
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
std::fs::copy(fixture(1), world.0.join("plugins/broken/plugin.prev.wasm")).unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"broken"})).await;
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
activate(&host, "broken").await.unwrap();
|
||||
std::fs::write(world.0.join("plugins/broken/plugin.wasm"), b"broken again").unwrap();
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/2.json"),
|
||||
consumer("broken", document("bad")).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
graph(&host).await["plugins"]["notes"]["imports"]["broken"]["status"],
|
||||
"unavailable"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// H2: a stray file under plugins does not interrupt discovery or activation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_stray_file() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::write(world.0.join("plugins/notes.md"), "a note").unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(graph(&host).await["plugins"]["notes"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// M4: an answered discovery receives EOF instead of leaking a detached process.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_discovery_answer_stops() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-linger"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
marker(world.0.join("state/notes/discovery-ended-2"), "ended").await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// Checks that a rejected discovery never receives a retirement hook.
|
||||
async fn review_candidate_is_not_old_version() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let world = TestWorld::new();
|
||||
let mut rejected = consumer("missing", document("add"));
|
||||
rejected["x-test-linger"] = json!(true);
|
||||
rejected["x-test-stubborn"] = json!(true);
|
||||
world.plugin("notes", document("add"), rejected);
|
||||
std::fs::write(
|
||||
world.0.join("state/notes/3.json"),
|
||||
document("add").to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::copy(fixture(3), world.0.join("state/notes/third.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
assert!(activate(&host, "notes").await.is_err());
|
||||
let candidate = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.method == "rpc.discover")
|
||||
.unwrap()
|
||||
.0;
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/third.wasm"}),
|
||||
)
|
||||
.await;
|
||||
call(&host, "core.kill", json!({"pid":candidate})).await;
|
||||
let old = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()));
|
||||
marker(world.0.join("state/notes/retired"), &old).await;
|
||||
let rejected_hash = format!("{:x}", Sha256::digest(std::fs::read(fixture(2)).unwrap()));
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &rejected_hash)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// M6: shutdown suppresses service restarts and retirement hooks from pending upgrades.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_shutdown_suppresses_hooks() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("serve");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old, document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
marker(world.0.join("state/notes/service"), "1").await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
assert!(host.processes().is_empty());
|
||||
assert!(!world.0.join("state/notes/retired").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/notes/service")).unwrap(),
|
||||
"1"
|
||||
);
|
||||
assert!(
|
||||
call(&host, "core.log", json!({"grep":"retired start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
call(&host, "core.log", json!({"grep":"service start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
/// Checks retirement against live admissions after a revert.
|
||||
async fn review_retirement_checks_live_version() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("hold");
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("notes", old.clone(), old);
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let first = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, p)| p.method == "hold")
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let second = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(pid, p)| p.method == "hold" && *pid != first)
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
// The v2 retirement from the revert is unrelated to the pending v1 retirement.
|
||||
let previous = {
|
||||
use sha2::{Digest, Sha256};
|
||||
format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()))
|
||||
};
|
||||
call(&host, "core.stop", json!({"pid":first})).await;
|
||||
assert!(
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(300),
|
||||
marker(world.0.join("state/notes/retired"), &previous)
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
call(&host, "core.stop", json!({"pid":second})).await;
|
||||
marker(world.0.join("state/notes/retired"), &previous).await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// T23: an unwritable compile cache does not prevent a world from running.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unwritable_compile_cache_is_nonfatal() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let parent = world.0.join("unwritable");
|
||||
std::fs::create_dir(&parent).unwrap();
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
|
||||
let result = Host::start_with_cache(&world.0, &parent.join("cache")).await;
|
||||
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"1"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: source subtrees require no watches, even when the host cannot read them.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_is_shallow() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let source = world.0.join("plugins/notes/source");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o0)).unwrap();
|
||||
let result = Host::start_with_cache(
|
||||
&world.0,
|
||||
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await;
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// L13: a watcher startup error reaches the journal without preventing host startup.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_watcher_failure_is_nonfatal() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let inaccessible = world.0.join("plugins/inaccessible");
|
||||
std::fs::create_dir_all(&inaccessible).unwrap();
|
||||
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o0)).unwrap();
|
||||
let result = Host::start_with_cache(
|
||||
&world.0,
|
||||
Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
|
||||
)
|
||||
.await;
|
||||
std::fs::set_permissions(&inaccessible, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let host = result.unwrap();
|
||||
assert!(
|
||||
!call(&host, "core.log", json!({"grep":"watcher failed"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// H2: a plugin read error belongs to that entry, not the entire graph.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_read_error_is_local() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
std::fs::create_dir_all(world.0.join("plugins/broken/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(!graph(&host).await["plugins"]["broken"]["error"].is_null());
|
||||
host.stop().await;
|
||||
}
|
||||
/// L3: cancellation of the caller cannot cancel an activation body.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn review_dropped_activation_completes() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let task = tokio::spawn(async move { activate(&caller, "notes").await });
|
||||
marker(world.0.join("state/notes/discover-started"), "ready").await;
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap()
|
||||
!= std::fs::read(fixture(2)).unwrap()
|
||||
{
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.1: discovery fails after its fixed ten-second deadline and receives EOF.
|
||||
/// PROTOCOL 3.7: old processes retain their declared grace while new calls use new code.
|
||||
/// PROTOCOL 3.7: retirement waits for all live admissions, including reverted versions.
|
||||
/// PROTOCOL 3.7: rejected discovery candidates never receive retirement hooks.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn upgrade_and_discovery_deadlines() {
|
||||
tokio::join!(
|
||||
review_discovery_deadline_and_stop(),
|
||||
old_process_grace_and_new_calls(),
|
||||
review_retirement_checks_live_version(),
|
||||
review_candidate_is_not_old_version(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Checks the fixed discovery deadline and subsequent process stop.
|
||||
async fn review_discovery_deadline_and_stop() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-no-answer"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let before = std::time::Instant::now();
|
||||
let result = tokio::time::timeout(Duration::from_secs(15), activate(&host, "notes")).await;
|
||||
assert!(
|
||||
result.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("discovery")
|
||||
);
|
||||
assert!(before.elapsed() >= Duration::from_millis(9900));
|
||||
assert!(
|
||||
!host
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.method == "rpc.discover" && process.state == "running")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.4: proc graph files contain valid documents before discovery completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn graph_is_valid_immediately() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
assert!(graph(&host).await["plugins"].is_object());
|
||||
let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await;
|
||||
assert!(dot.as_str().unwrap().starts_with("digraph plugins"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: a rejected group leaves every installed file unchanged.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rejects_group_without_changes() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("other"));
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", document("add")),
|
||||
consumer("notes", document("missing")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
let result = host.call("core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
||||
assert_eq!(result.unwrap_err()["code"], -32003);
|
||||
for name in ["notes", "agent"] {
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join(format!("plugins/{name}/plugin.wasm"))).unwrap(),
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: activation discovers and installs a copied candidate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn activates_copy() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
let result = activate(&host, "notes").await.unwrap();
|
||||
assert!(result["notes"]["methods"].is_array());
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"2"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: changes to the original after discovery starts cannot change the installed copy.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn source_changes_do_not_change_candidate() {
|
||||
let world = TestWorld::new();
|
||||
let mut next = document("add");
|
||||
next["x-test-wait"] = json!(true);
|
||||
world.plugin("notes", document("add"), next);
|
||||
let host = world.start().await;
|
||||
let activation_host = host.clone();
|
||||
let activation = tokio::spawn(async move { activate(&activation_host, "notes").await });
|
||||
marker(world.0.join("state/notes/discover-started"), "ready").await;
|
||||
std::fs::copy(fixture(1), world.0.join("state/notes/next.wasm")).unwrap();
|
||||
std::fs::write(world.0.join("state/notes/discover-release"), "ready").unwrap();
|
||||
activation.await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
||||
std::fs::read(fixture(2)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 3.7: plugins without a declared grace period retain old calls before the default deadline.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn default_grace_retains_old_process() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("hold");
|
||||
old.as_object_mut().unwrap().remove("x-sausage-grace");
|
||||
world.plugin("notes", old, document("hold"));
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let pid = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.method == "hold")
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(host.processes()[&pid].state, "running");
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: candidate imports must fit installed providers.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn checks_candidate_imports() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin(
|
||||
"notes",
|
||||
document("add"),
|
||||
consumer("absent", document("add")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
let failure = activate(&host, "notes").await.unwrap_err();
|
||||
assert_eq!(failure["code"], -32003);
|
||||
assert_eq!(failure["data"][0]["status"], "missing");
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: current consumers block an incompatible provider upgrade.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn checks_current_consumers() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("other"));
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", document("add")),
|
||||
document("consume"),
|
||||
);
|
||||
let host = world.start().await;
|
||||
let failure = activate(&host, "notes").await.unwrap_err();
|
||||
assert_eq!(failure["code"], -32003);
|
||||
assert_eq!(failure["data"][0]["method"], "add");
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: a group checks the proposed set rather than intermediate versions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn activates_group() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("other"));
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", document("add")),
|
||||
consumer("notes", document("other")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
call(&host,"core.activate",json!({"plugins":[{"plugin":"notes","wasm":"/state/notes/next.wasm"},{"plugin":"agent","wasm":"/state/agent/next.wasm"}]})).await;
|
||||
assert_eq!(
|
||||
call(&host, "agent.version", json!({})).await["version"],
|
||||
"2"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: force skips compatibility checks.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn force_skips_checks() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin(
|
||||
"notes",
|
||||
document("add"),
|
||||
consumer("absent", document("add")),
|
||||
);
|
||||
let host = world.start().await;
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/state/notes/next.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: revert activates the previous copy and preserves the replaced version.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reverts_previous_version() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"1"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
||||
std::fs::read(fixture(2)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.3: concurrent activations serialize their installation and revert state.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn serializes_activations() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
let (first, second) = tokio::join!(activate(&host, "notes"), activate(&host, "notes"));
|
||||
first.unwrap();
|
||||
second.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.prev.wasm")).unwrap(),
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.4: startup fetches every installed document before activation checks consumers.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn discovers_all_before_checks() {
|
||||
checks_current_consumers_body().await;
|
||||
}
|
||||
/// Exercises a consumer whose discovery must finish before the provider can change.
|
||||
async fn checks_current_consumers_body() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("other"));
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", document("add")),
|
||||
document("consume"),
|
||||
);
|
||||
let host = world.start().await;
|
||||
assert_eq!(activate(&host, "notes").await.unwrap_err()["code"], -32003);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.4: manual copies update cached hashes and expose broken imports in the journal.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn watches_manual_copy() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("other"));
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", document("add")),
|
||||
document("consume"),
|
||||
);
|
||||
let host = world.start().await;
|
||||
// A same-version activation waits for initial discovery without changing the provider.
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm"}),
|
||||
)
|
||||
.await;
|
||||
std::fs::copy(fixture(2), world.0.join("plugins/notes/plugin.wasm")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
loop {
|
||||
let graph = graph(&host).await;
|
||||
if graph["plugins"]["agent"]["imports"]["notes"]["status"] == "broken" {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
!call(&host, "core.log", json!({"grep":"broken import"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 10.4: nonexistent providers appear as missing imports.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn graph_missing_import() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin(
|
||||
"notes",
|
||||
consumer("absent", document("add")),
|
||||
document("add"),
|
||||
);
|
||||
let host = world.start().await;
|
||||
call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"notes","wasm":"/plugins/notes/plugin.wasm","force":true}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
graph(&host).await["plugins"]["notes"]["imports"]["absent"]["status"],
|
||||
"missing"
|
||||
);
|
||||
let dot = call(&host, "notes.read", json!({"path":"/proc/core/graph.dot"})).await;
|
||||
assert!(dot.as_str().unwrap().contains("absent"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Checks the declared upgrade grace and dispatch to the new version.
|
||||
async fn old_process_grace_and_new_calls() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("add");
|
||||
old["x-sausage-grace"] = json!(0.5);
|
||||
world.plugin("notes", old, document("add"));
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
let pid = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.method == "hold")
|
||||
.unwrap()
|
||||
.0;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert!(host.processes().contains_key(&pid));
|
||||
assert_eq!(
|
||||
call(&host, "notes.version", json!({})).await["version"],
|
||||
"2"
|
||||
);
|
||||
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 3.7: retirement receives the previous hash only after old processes end.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn retires_old_version() {
|
||||
use sha2::{Digest, Sha256};
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
let previous = format!("{:x}", Sha256::digest(std::fs::read(fixture(1)).unwrap()));
|
||||
marker(world.0.join("state/notes/retired"), &previous).await;
|
||||
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
||||
assert!(
|
||||
!call(&host, "core.log", json!({"grep":"retired start"}))
|
||||
.await
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 8 and 3.7: services restart after their old process ends.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restarts_service() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("serve"), document("serve"));
|
||||
std::fs::write(
|
||||
world.0.join("config/boot.json"),
|
||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
marker(world.0.join("state/notes/service"), "1").await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
marker(world.0.join("state/notes/service"), "2").await;
|
||||
host.stop().await;
|
||||
}
|
||||
/// PROTOCOL 8: ordinary detached processes do not become services after an upgrade.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn does_not_restart_detached_call() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("hold"), document("hold"));
|
||||
let host = world.start().await;
|
||||
call(
|
||||
&host,
|
||||
"core.call",
|
||||
json!({"method":"notes.hold","params":{},"detach":true}),
|
||||
)
|
||||
.await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
marker(world.0.join("state/notes/ended-1"), "ended").await;
|
||||
assert!(
|
||||
!host
|
||||
.processes()
|
||||
.values()
|
||||
.any(|process| process.method == "hold" && process.state == "running")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 7: activation records the plugin's upgrade grace period in the journal.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn upgrades_enter_the_journal() {
|
||||
let world = TestWorld::new();
|
||||
let mut old = document("hold");
|
||||
old.as_object_mut().unwrap().remove("x-sausage-grace");
|
||||
world.plugin("notes", old, document("hold"));
|
||||
let host = world.start().await;
|
||||
call(&host, "notes.hold", json!({})).await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
let entries = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let entries = call(
|
||||
&host,
|
||||
"core.log",
|
||||
json!({"plugin":"notes","grep":"upgrade grace"}),
|
||||
)
|
||||
.await;
|
||||
if entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e["line"] == "upgrade grace 60 seconds")
|
||||
{
|
||||
break entries;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("the upgrade journal entry must arrive within 10 seconds");
|
||||
assert!(
|
||||
entries
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|e| e["stream"] == "core")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.2 and 10.3: breaking endpoint methods reject activation with counterexamples where possible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn endpoint_contracts_and_activation_counterexamples() {
|
||||
let method = json!({"name":"stats","params":[{"name":"value","required":true,"schema":{"type":"integer","minimum":0}}],"result":{"schema":{"type":"string"}}});
|
||||
let expected = json!({"methods":[],"x-sausage-endpoints":{"control":{"methods":[method]}}});
|
||||
for case in ["missing", "params", "result", "unsupported"] {
|
||||
let world = TestWorld::new();
|
||||
let mut next = expected.clone();
|
||||
match case {
|
||||
"missing" => next["x-sausage-endpoints"]["control"]["methods"] = json!([]),
|
||||
"params" => {
|
||||
next["x-sausage-endpoints"]["control"]["methods"][0]["params"][0]["schema"]["minimum"] =
|
||||
json!(1)
|
||||
}
|
||||
"result" => {
|
||||
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"] =
|
||||
json!({"type":"integer"})
|
||||
}
|
||||
_ => {
|
||||
next["x-sausage-endpoints"]["control"]["methods"][0]["result"]["schema"]["pattern"] =
|
||||
json!("[a-z]+")
|
||||
}
|
||||
}
|
||||
world.plugin("notes", expected.clone(), next);
|
||||
world.plugin(
|
||||
"agent",
|
||||
consumer("notes", expected.clone()),
|
||||
document("consume"),
|
||||
);
|
||||
let host = world.start().await;
|
||||
let error = activate(&host, "notes").await.unwrap_err();
|
||||
assert_eq!(error["code"], -32003, "{case}: {error}");
|
||||
assert!(!error["data"].as_array().unwrap().is_empty());
|
||||
if ["params", "result"].contains(&case) {
|
||||
assert!(
|
||||
error["data"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|p| !p["counterexample"].is_null()),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
if case == "unsupported" {
|
||||
assert!(error["data"].to_string().contains("unsupported"), "{error}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(world.0.join("plugins/notes/plugin.wasm")).unwrap(),
|
||||
std::fs::read(fixture(1)).unwrap()
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.2: providers can add methods, optional params, and result fields without breaking consumers.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn additive_contract_changes_activate() {
|
||||
let old = json!({"methods":[{"name":"read","params":[],"result":{"schema":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}}}]});
|
||||
let mut next = old.clone();
|
||||
next["methods"][0]["params"] =
|
||||
json!([{"name":"limit","required":false,"schema":{"type":"integer"}}]);
|
||||
next["methods"][0]["result"]["schema"]["properties"]["extra"] = json!({"type":"boolean"});
|
||||
next["methods"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"name":"new","params":[],"result":{"schema":{"type":"null"}}}));
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", old.clone(), next);
|
||||
world.plugin("agent", consumer("notes", old), document("consume"));
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.7: an old consumer can still send its old import during grace, even after the provider removes it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn old_consumer_can_call_removed_provider_method() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("legacy"), document("new"));
|
||||
let mut old = consumer("notes", document("legacy"));
|
||||
old["x-sausage-grace"] = json!(60);
|
||||
world.plugin("agent", old, document("consume"));
|
||||
let host = world.start().await;
|
||||
let pid = call(&host, "agent.deferred", json!({})).await;
|
||||
activate(&host, "agent").await.unwrap();
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(host.processes()[pid.as_str().unwrap()].state, "running");
|
||||
std::fs::write(world.0.join("state/agent/release-call"), "release").unwrap();
|
||||
let response: Value = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Ok(bytes) = std::fs::read(world.0.join("state/agent/call-result"))
|
||||
&& let Ok(value) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
break value;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response["error"]["code"], -32601);
|
||||
assert_eq!(response["error"]["data"]["version"], "2");
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 10.2: activation and revert check interfaces but leave persistent state unchanged.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn state_is_outside_interface_checks() {
|
||||
let world = TestWorld::new();
|
||||
world.plugin("notes", document("add"), document("add"));
|
||||
let state = world.0.join("state/notes/user-state");
|
||||
let contents = b"a state format neither version understands\0";
|
||||
std::fs::write(&state, contents).unwrap();
|
||||
let host = world.start().await;
|
||||
activate(&host, "notes").await.unwrap();
|
||||
assert_eq!(std::fs::read(&state).unwrap(), contents);
|
||||
call(&host, "core.revert", json!({"plugin":"notes"})).await;
|
||||
assert_eq!(std::fs::read(state).unwrap(), contents);
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers.
|
||||
use bytes::Bytes;
|
||||
use http_body_util::{BodyExt, Full};
|
||||
use hyper::{Response, server::conn::http1, service::service_fn};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use sausage::{Host, world::World};
|
||||
use sausage_vfs::Layer;
|
||||
use serde_json::{Value, json};
|
||||
use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration};
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{mpsc, oneshot},
|
||||
};
|
||||
|
||||
/// The two HTTP contracts supported by the agent.
|
||||
const APIS: [&str; 2] = ["openai-responses", "anthropic-messages"];
|
||||
/// A captured request and the script's delayed HTTP response.
|
||||
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
|
||||
/// The script controls each response, including concurrent requests.
|
||||
struct Llm {
|
||||
url: String,
|
||||
api: &'static str,
|
||||
requests: mpsc::UnboundedReceiver<Exchange>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Llm {
|
||||
async fn new(api: &'static str) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let service =
|
||||
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
|
||||
let tx = tx.clone();
|
||||
async move {
|
||||
let path = request.uri().path().to_string();
|
||||
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
||||
let body = serde_json::from_slice(&bytes).unwrap();
|
||||
let (reply, answer) = oneshot::channel();
|
||||
tx.send((path, body, reply)).unwrap();
|
||||
let (status, body) = answer.await.unwrap();
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Full::new(Bytes::from(body.to_string())))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
Self {
|
||||
url,
|
||||
api,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
|
||||
let (path, body, reply) =
|
||||
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
path,
|
||||
if self.api == APIS[0] {
|
||||
"/v1/responses"
|
||||
} else {
|
||||
"/v1/messages"
|
||||
}
|
||||
);
|
||||
assert_eq!(body["model"], "scripted-test");
|
||||
(body, reply)
|
||||
}
|
||||
fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
|
||||
if self.api == APIS[0] {
|
||||
let mut output =
|
||||
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()}));
|
||||
}
|
||||
json!({"status":"completed","output":output})
|
||||
} else {
|
||||
let mut content = vec![json!({"type":"text","text":text})];
|
||||
for (n, (name, args)) in calls.iter().enumerate() {
|
||||
content.push(
|
||||
json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}),
|
||||
);
|
||||
}
|
||||
json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}})
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Llm {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
fn fixtures() -> &'static PathBuf {
|
||||
static TARGET: OnceLock<PathBuf> = OnceLock::new();
|
||||
TARGET.get_or_init(|| {
|
||||
super::common::build("agent", "agent");
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new(server: &Llm) -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&path).unwrap();
|
||||
for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] {
|
||||
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();
|
||||
}
|
||||
std::fs::write(path.join("config/agent.json"),json!({"api":server.api,"base_url":server.url,"model":"scripted-test","token":"fake-token"}).to_string()).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()
|
||||
}
|
||||
fn history(&self) -> String {
|
||||
std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle<Value> {
|
||||
let host = host.clone();
|
||||
let text = text.to_owned();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::timeout(
|
||||
Duration::from_secs(90),
|
||||
host.call("agent.chat", json!({"text":text})),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
})
|
||||
}
|
||||
fn input(body: &Value, api: &str) -> Value {
|
||||
body[if api == APIS[0] { "input" } else { "messages" }].clone()
|
||||
}
|
||||
fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
||||
if api == APIS[0] {
|
||||
body["input"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e["type"] == "function_call_output")
|
||||
.map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
} else {
|
||||
body["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|e| e["content"].as_array().unwrap())
|
||||
.filter(|e| e["type"] == "tool_result")
|
||||
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Both API shapes return final text and retain the complete history after a core restart.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_answers_and_history_survive_restart() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "first request alpha");
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(
|
||||
input(&body, api)
|
||||
.to_string()
|
||||
.contains("first request alpha")
|
||||
);
|
||||
assert_eq!(body["tools"].as_array().unwrap().len(), 5);
|
||||
reply
|
||||
.send((200, server.answer("first answer alpha", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first answer alpha");
|
||||
host.stop().await;
|
||||
let prefix = world.history();
|
||||
drop(host);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "second request beta");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
for expected in [
|
||||
"first request alpha",
|
||||
"first answer alpha",
|
||||
"second request beta",
|
||||
] {
|
||||
assert!(text.contains(expected));
|
||||
}
|
||||
reply
|
||||
.send((200, server.answer("second answer beta", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "second answer beta");
|
||||
assert!(world.history().starts_with(&prefix));
|
||||
let entries: Vec<Value> = world
|
||||
.history()
|
||||
.lines()
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| e["kind"].as_str().unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
["user", "assistant", "user", "assistant"]
|
||||
);
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Both API shapes execute every tool, return tool errors, and deliver interim text.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn tools_and_interim_notifications() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let mut events = host.subscribe();
|
||||
let turn = chat(&host, "exercise the tools");
|
||||
let (_, reply) = server.next().await;
|
||||
let calls = [
|
||||
(
|
||||
"call",
|
||||
json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}),
|
||||
),
|
||||
("read_file", json!({"path":"/proc/core/PROTOCOL.md"})),
|
||||
(
|
||||
"write_file",
|
||||
json!({"path":"/state/work/source/note.txt","content":"saved"}),
|
||||
),
|
||||
("list_dir", json!({"path":"/state/work/source"})),
|
||||
(
|
||||
"copy_dir",
|
||||
json!({"from":"/state/work/source","to":"/state/work/copied"}),
|
||||
),
|
||||
("read_file", json!({"path":"/state/work/missing"})),
|
||||
("call", json!({"method":"absent.run","params":{}})),
|
||||
("call", json!({"method":"probe.fail","params":{}})),
|
||||
];
|
||||
reply
|
||||
.send((200, server.answer("I will inspect the world.", &calls)))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let results = outputs(&body, api);
|
||||
assert_eq!(results.len(), 8);
|
||||
assert_eq!(results[0], 9);
|
||||
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
|
||||
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
|
||||
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
|
||||
assert_eq!(results[4]["copied_files"], 1);
|
||||
assert!(results[5]["error"]["message"].is_string());
|
||||
assert!(results[6]["error"]["code"].is_number());
|
||||
assert_eq!(
|
||||
results[7]["error"],
|
||||
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
|
||||
"saved"
|
||||
);
|
||||
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(event["params"]["name"], "text");
|
||||
assert_eq!(
|
||||
event["params"]["params"]["text"],
|
||||
"I will inspect the world."
|
||||
);
|
||||
reply
|
||||
.send((200, server.answer("The tools finished.", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "The tools finished.");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// The history lock holds the second chat until the complete first turn ends.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_chats_serialize_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "first concurrent request");
|
||||
let (_, reply) = server.next().await;
|
||||
let second = chat(&host, "second concurrent request");
|
||||
let view = host.filesystem().await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
let file = view
|
||||
.open("/proc/core/locks.json", sausage_vfs::Open::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let locks: Value =
|
||||
serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
|
||||
if locks["/state/agent/history.jsonl"]["waiting"]
|
||||
.as_array()
|
||||
.is_some_and(|waiting| waiting.len() == 1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(server.requests.try_recv().is_err());
|
||||
reply
|
||||
.send((200, server.answer("first concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(first.await.unwrap(), "first concurrent answer");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(text.contains("first concurrent answer"));
|
||||
assert!(text.contains("second concurrent request"));
|
||||
reply
|
||||
.send((200, server.answer("second concurrent answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "second concurrent answer");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Context-size failures discard old complete turns without changing the append-only history.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn context_window_advances_by_complete_turns() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, server.answer("old answer", &[]))).unwrap();
|
||||
first.await.unwrap();
|
||||
let second = chat(&host, "current turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((400, json!({"error":"context_length_exceeded"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old turn"));
|
||||
assert!(!text.contains("old answer"));
|
||||
assert!(text.contains("current turn"));
|
||||
reply
|
||||
.send((200, server.answer("current answer", &[])))
|
||||
.unwrap();
|
||||
assert_eq!(second.await.unwrap(), "current answer");
|
||||
assert_eq!(world.history().lines().count(), 4);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn oversized_current_turn_preserves_the_previous_cursor() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
|
||||
let host = world.start().await;
|
||||
let first = chat(&host, "old retained turn");
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((200, server.answer("old retained answer", &[])))
|
||||
.unwrap();
|
||||
first.await.unwrap();
|
||||
let skip = world.0.join("state/agent/history.skip");
|
||||
std::fs::write(&skip, "0").unwrap();
|
||||
let caller = host.clone();
|
||||
let turn = tokio::spawn(async move {
|
||||
caller
|
||||
.call("agent.chat", json!({"text":"read a large tool result"}))
|
||||
.await
|
||||
});
|
||||
let (_, reply) = server.next().await;
|
||||
reply
|
||||
.send((
|
||||
200,
|
||||
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
|
||||
))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
let serialized = input(&body, api).to_string();
|
||||
assert!(serialized.contains("[tool output truncated"));
|
||||
assert!(serialized.len() < 70_000);
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
let (body, reply) = server.next().await;
|
||||
assert!(!input(&body, api).to_string().contains("old retained turn"));
|
||||
reply
|
||||
.send((413, json!({"type":"request_too_large"})))
|
||||
.unwrap();
|
||||
assert!(turn.await.unwrap().is_err());
|
||||
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider-private items are replayed only for their original API and model.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn private_items_are_scoped_to_the_model() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let config_path = world.0.join("config/agent.json");
|
||||
let mut config: Value =
|
||||
serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
|
||||
config["thinking"] = json!("high");
|
||||
std::fs::write(config_path, config.to_string()).unwrap();
|
||||
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
|
||||
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
||||
for (model, marker) in [
|
||||
("another-model", "old-private-marker"),
|
||||
("scripted-test", "current-private-marker"),
|
||||
] {
|
||||
let item = if api == APIS[0] {
|
||||
json!({"type":"reasoning","encrypted_content":marker})
|
||||
} else {
|
||||
json!({"type":"thinking","thinking":"private","signature":marker})
|
||||
};
|
||||
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
|
||||
}
|
||||
entries.push(json!({"kind":"assistant","text":"old answer"}));
|
||||
std::fs::write(
|
||||
world.0.join("state/agent/history.jsonl"),
|
||||
entries
|
||||
.iter()
|
||||
.map(|entry| format!("{entry}\n"))
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "next turn");
|
||||
let (body, reply) = server.next().await;
|
||||
let text = input(&body, api).to_string();
|
||||
assert!(!text.contains("old-private-marker"));
|
||||
assert!(text.contains("current-private-marker"));
|
||||
reply.send((200, server.answer("done", &[]))).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "done");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Separate assistant messages retain a separator in the final answer.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn final_texts_have_a_separator() {
|
||||
for api in APIS {
|
||||
let mut server = Llm::new(api).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let turn = chat(&host, "two paragraphs");
|
||||
let (_, reply) = server.next().await;
|
||||
let mut answer = server.answer("first", &[]);
|
||||
if api == APIS[0] {
|
||||
answer["output"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
|
||||
} else {
|
||||
answer["content"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.push(json!({"type":"text","text":"second"}));
|
||||
}
|
||||
reply.send((200, answer)).unwrap();
|
||||
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// An incomplete response containing reasoning but no text is an error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn incomplete_without_text_is_an_error() {
|
||||
let mut server = Llm::new(APIS[0]).await;
|
||||
let world = TestWorld::new(&server);
|
||||
let host = world.start().await;
|
||||
let caller = host.clone();
|
||||
let turn =
|
||||
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
|
||||
let (_, reply) = server.next().await;
|
||||
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
|
||||
assert!(
|
||||
turn.await.unwrap().unwrap_err()["message"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("incomplete response")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -0,0 +1,706 @@
|
||||
//! Native build and WASI libtest integration checks under PROTOCOL.md section 11.
|
||||
use super::common::CACHE;
|
||||
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_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let runner = world.0.join("state/runner with spaces");
|
||||
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_sausage"), &runner).unwrap();
|
||||
sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner, Path::new(CACHE))
|
||||
.unwrap()
|
||||
.register(&host);
|
||||
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 nested = world.0.join("plugins/valid/source/src/target");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("data"), "nested target").unwrap();
|
||||
let git = world.0.join("plugins/valid/source/src/.git");
|
||||
std::fs::create_dir_all(&git).unwrap();
|
||||
std::fs::write(git.join("data"), "nested git").unwrap();
|
||||
for number in 1..=20 {
|
||||
std::fs::create_dir_all(world.0.join(format!("state/cargo/builds/{number}")))
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(world.0.join("state/cargo/tmp/stale")).unwrap();
|
||||
std::fs::write(world.0.join("state/cargo/tmp/stale/file"), "stale").unwrap();
|
||||
let result = build(&world, "valid")
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("build failed: {error}"));
|
||||
// Integration tests also build a normal binary. Cargo identifies the libtest artifact.
|
||||
let artifacts = tokio::process::Command::new("cargo")
|
||||
.args([
|
||||
"test",
|
||||
"--no-run",
|
||||
"--release",
|
||||
"--message-format=json",
|
||||
"--bin",
|
||||
"cargo-valid",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(Path::new(CACHE).join("cargo-target"))
|
||||
.current_dir(world.0.join("state/cargo/work"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
artifacts.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&artifacts.stderr)
|
||||
);
|
||||
let mut tests: Vec<PathBuf> = String::from_utf8_lossy(&artifacts.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|message| {
|
||||
message["reason"] == "compiler-artifact"
|
||||
&& message["profile"]["test"] == true
|
||||
&& message["target"]["kind"]
|
||||
.as_array()
|
||||
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
|
||||
})
|
||||
.filter_map(|message| message["executable"].as_str().map(PathBuf::from))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tests.len(),
|
||||
1,
|
||||
"expected exactly one libtest binary: {tests:?}"
|
||||
);
|
||||
let test = tests.pop().unwrap();
|
||||
Valid {
|
||||
world,
|
||||
result,
|
||||
test,
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
/// Invokes the same runner subcommand that cargo uses.
|
||||
async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output {
|
||||
let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"));
|
||||
command
|
||||
.arg("test-runner")
|
||||
.arg(&valid.test)
|
||||
.args(args)
|
||||
.current_dir(valid.world.0.join("state/cargo/work"))
|
||||
.kill_on_drop(true);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), command.output())
|
||||
.await
|
||||
.expect("test runner exceeded 120 seconds")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// T23: unlocks stay ordered before the next lock over 300 acquisitions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unlock_order() {
|
||||
assert!(
|
||||
runner(valid().await, &["unlock_order"])
|
||||
.await
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
}
|
||||
/// T23: each runner world is empty and has independent proc, self, and run mounts.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_and_isolated_worlds() {
|
||||
for name in ["empty_world", "isolation_", "environment", "late_response"] {
|
||||
let output = runner(valid().await, &[name]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{name}: {} {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
/// T23: successful builds preserve warnings and integration output without cargo progress noise.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn build_output_and_warnings() {
|
||||
let valid = valid().await;
|
||||
assert!(
|
||||
valid.result["warnings"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("fixture build warning")
|
||||
);
|
||||
let output = valid.result["tests"]["output"].as_str().unwrap();
|
||||
assert!(output.contains("INTEGRATION_CASE"));
|
||||
assert!(!output.contains("Compiling "));
|
||||
assert!(!output.contains(" running 1 test"));
|
||||
assert!(
|
||||
!output
|
||||
.lines()
|
||||
.any(|line| line.starts_with(" ") && line.trim().is_empty())
|
||||
);
|
||||
}
|
||||
/// T23: publication retains ten builds and the next build removes abandoned test worlds.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn build_retention_and_temporary_cleanup() {
|
||||
let valid = valid().await;
|
||||
assert_eq!(
|
||||
std::fs::read_dir(valid.world.0.join("state/cargo/builds"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
10
|
||||
);
|
||||
assert!(!valid.world.0.join("state/cargo/tmp/stale").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(valid.world.0.join("state/cargo/tmp"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
/// T23: manifest preparation preserves workspaces, renamed target dependencies, and optional flags.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manifest_and_nested_sources() {
|
||||
let valid = valid().await;
|
||||
let root = valid.world.0.join("state/cargo/work");
|
||||
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
|
||||
assert!(manifest.contains("workspace.package"));
|
||||
assert!(manifest.contains("optional = true"));
|
||||
let document: toml_edit::DocumentMut = manifest.parse().unwrap();
|
||||
let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap());
|
||||
assert_eq!(sdk.parent().unwrap(), Path::new(CACHE).join("sdk"));
|
||||
let hash = sdk.file_name().unwrap().to_str().unwrap();
|
||||
assert_eq!(hash.len(), 64);
|
||||
assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
|
||||
assert_eq!(
|
||||
std::fs::read(sdk.join("src/lib.rs")).unwrap(),
|
||||
std::fs::read(valid.world.0.join("plugins/cargo/sdk/src/lib.rs")).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("src/target/data")).unwrap(),
|
||||
"nested target"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("src/.git/data")).unwrap(),
|
||||
"nested git"
|
||||
);
|
||||
}
|
||||
/// T23: discovery describes the native build method and preparation errors name their paths.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn native_discovery_and_error_paths() {
|
||||
let world = TestWorld::new();
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
host.call("cargo.rpc.discover", json!({})).await.unwrap()["methods"][0]["name"],
|
||||
"build"
|
||||
);
|
||||
let error = host
|
||||
.call("cargo.build", json!({"plugin":"missing"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32004);
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("plugins/missing/source")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Rework 3: an unavailable cache prevents builds, not host startup or SDK installation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cache_failure_is_deferred_until_build() {
|
||||
let world = TestWorld::new();
|
||||
world.source("simple", &fixture("cargo-test-fail"));
|
||||
// A file blocks directory creation even when the test runs as root.
|
||||
let cache = world.0.join("blocked-cache");
|
||||
std::fs::write(&cache, "not a directory").unwrap();
|
||||
let host = Host::start_with_cache(&world.0, &cache).await.unwrap();
|
||||
assert!(world.0.join("plugins/cargo/sdk/src/lib.rs").is_file());
|
||||
let error = host
|
||||
.call("cargo.build", json!({"plugin":"simple"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32004);
|
||||
let output = error["data"]["output"].as_str().unwrap();
|
||||
assert!(output.contains(cache.to_str().unwrap()), "{output}");
|
||||
assert!(output.contains("create"), "{output}");
|
||||
host.stop().await;
|
||||
|
||||
let standalone = TestWorld::new();
|
||||
standalone.source("simple", &fixture("cargo-test-fail"));
|
||||
let builder = sausage::cargo::Cargo::with_runner(
|
||||
World::open(&standalone.0).unwrap(),
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
||||
&cache,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(standalone.0.join("plugins/cargo/sdk/src/lib.rs").is_file());
|
||||
let error = builder
|
||||
.build(&standalone.0.join("plugins/simple/source"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.output.contains(cache.to_str().unwrap()), "{error}");
|
||||
assert!(cache.is_file());
|
||||
}
|
||||
/// T23: release-only failures use data.output and retain successful WASI totals.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn release_only_failure() {
|
||||
let world = TestWorld::new();
|
||||
let root = world.0.join("plugins/release/source/src");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.parent().unwrap().join("Cargo.toml"),
|
||||
"[package]\nname='release-only'\nversion='0.1.0'\nedition='2024'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("main.rs"), "#[cfg(not(test))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap();
|
||||
let error = build(&world, "release").await.unwrap_err();
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("release-only error")
|
||||
);
|
||||
assert_eq!(error["data"]["tests"]["passed"], 1);
|
||||
}
|
||||
/// T23: cancellation retains serialization until a blocking source copy completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cancelled_preparation_is_serialized() {
|
||||
let world = TestWorld::new();
|
||||
let first = world.0.join("first");
|
||||
let second = world.0.join("second");
|
||||
for (root, name) in [(&first, "first"), (&second, "second")] {
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("Cargo.toml"),
|
||||
format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
}
|
||||
for number in 0..10000 {
|
||||
std::fs::write(first.join(format!("old-{number}")), "copy").unwrap();
|
||||
}
|
||||
let builder = std::sync::Arc::new(
|
||||
sausage::cargo::Cargo::with_runner(
|
||||
World::open(&world.0).unwrap(),
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
||||
Path::new(CACHE),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let copying = builder.clone();
|
||||
let task = tokio::spawn(async move { copying.build(&first).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
if std::fs::read_dir(world.0.join("state/cargo/work"))
|
||||
.is_ok_and(|entries| entries.count() > 10)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
builder.build(&second).await.unwrap();
|
||||
assert!(
|
||||
!std::fs::read_dir(world.0.join("state/cargo/work"))
|
||||
.unwrap()
|
||||
.any(|entry| entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("old-"))
|
||||
);
|
||||
}
|
||||
/// T23: multiple binary artifacts fail clearly instead of publishing an arbitrary binary.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multiple_binaries() {
|
||||
let world = TestWorld::new();
|
||||
let root = world.0.join("plugins/multi/source/src/bin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.parent().unwrap().parent().unwrap().join("Cargo.toml"),
|
||||
"[package]\nname='multiple'\nversion='0.1.0'\nedition='2024'\n",
|
||||
)
|
||||
.unwrap();
|
||||
for name in ["one", "two"] {
|
||||
std::fs::write(root.join(format!("{name}.rs")), "fn main() {}\n").unwrap();
|
||||
}
|
||||
let error = build(&world, "multi").await.unwrap_err();
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("exactly one binary")
|
||||
);
|
||||
}
|
||||
/// 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"], 12);
|
||||
assert_eq!(valid.result["tests"]["failed"], 0);
|
||||
assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists());
|
||||
assert!(!valid.world.0.join("state/cargo/target").exists());
|
||||
assert!(
|
||||
Path::new(CACHE)
|
||||
.join("cargo-target/wasm32-wasip2/release")
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
/// 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(Path::new(CACHE).join("sdk").to_str().unwrap())
|
||||
);
|
||||
}
|
||||
/// 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")),
|
||||
Path::new(CACHE),
|
||||
)
|
||||
.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")),
|
||||
Path::new(CACHE),
|
||||
)
|
||||
.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() {
|
||||
assert!(
|
||||
Path::new("/proc/self/stat").is_file(),
|
||||
"process-group checks require /proc"
|
||||
);
|
||||
let world = TestWorld::new();
|
||||
world.source("hang", &fixture("cargo-hang"));
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.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 mut ancestor = child;
|
||||
loop {
|
||||
let command = std::fs::read(format!("/proc/{ancestor}/cmdline")).unwrap();
|
||||
let arguments: Vec<_> = command
|
||||
.split(|byte| *byte == 0)
|
||||
.map(|part| String::from_utf8_lossy(part).to_string())
|
||||
.collect();
|
||||
if arguments[0].ends_with("/cargo") || arguments[0] == "cargo" {
|
||||
assert!(!arguments.iter().any(|arg| arg == "--offline"));
|
||||
assert!(
|
||||
arguments
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("target.wasm32-wasip2.runner=["))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(format!("/proc/{ancestor}/fd/0")).unwrap(),
|
||||
PathBuf::from("/dev/null")
|
||||
);
|
||||
break;
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{ancestor}/status")).unwrap();
|
||||
ancestor = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(ancestor > 1, "cargo ancestor not found");
|
||||
}
|
||||
let pid = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.plugin == "cargo")
|
||||
.unwrap()
|
||||
.0;
|
||||
let fs = host.filesystem().await.unwrap();
|
||||
use sausage_vfs::Layer;
|
||||
assert!(
|
||||
fs.stat(&format!("/proc/{pid}/info.json"), true)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.call("core.log", json!({"pid":pid,"grep":"start build"}))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
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 match status {
|
||||
Ok(status) => status.split_whitespace().nth(2) == Some("Z"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(error) => panic!("read /proc/{child}/stat: {error}"),
|
||||
} {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::AsyncWriteExt,
|
||||
process::{Child, Command},
|
||||
};
|
||||
|
||||
use super::common::{CACHE, probe};
|
||||
|
||||
fn binary() -> Command {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
|
||||
command
|
||||
.arg("--cache-dir")
|
||||
.arg(CACHE)
|
||||
.env_remove("SAUSAGE_HEADLESS")
|
||||
.kill_on_drop(true);
|
||||
command
|
||||
}
|
||||
fn world() -> (tempfile::TempDir, PathBuf) {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let world = World::create(&folder.path().join("world")).unwrap();
|
||||
(folder, world.root)
|
||||
}
|
||||
async fn status(child: &mut Child) -> std::process::ExitStatus {
|
||||
tokio::time::timeout(Duration::from_secs(15), child.wait())
|
||||
.await
|
||||
.expect("CLI did not exit")
|
||||
.unwrap()
|
||||
}
|
||||
async fn journal_exists(world: &Path) {
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
while !world.join("state/core/journal.sqlite").exists() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
|
||||
nix::sys::signal::kill(
|
||||
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
|
||||
signal,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
fn install_probe(world: &Path) {
|
||||
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
|
||||
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
|
||||
}
|
||||
|
||||
/// A failed agent build leaves no world. Bare sausage retries and publishes a complete world.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn init_builds_agent() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
let world = temporary.path().join("world");
|
||||
let configured = || {
|
||||
let mut command = binary();
|
||||
command
|
||||
.current_dir(temporary.path())
|
||||
.env("SAUSAGE_API", "anthropic-messages")
|
||||
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
|
||||
.env("SAUSAGE_MODEL", "init-test")
|
||||
.env("SAUSAGE_API_TOKEN", "fake-token")
|
||||
.stdin(Stdio::null());
|
||||
command
|
||||
};
|
||||
let failed = configured()
|
||||
.arg("init")
|
||||
.env("RUSTC", "/nonexistent-sausage-rustc")
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!failed.status.success());
|
||||
assert!(!world.exists());
|
||||
let output = configured().output().await.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"stdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
||||
);
|
||||
let config: Value =
|
||||
serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
config,
|
||||
json!({"api":"anthropic-messages","base_url":"http://127.0.0.1:1","model":"init-test","token":"fake-token"})
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("sausage run"));
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&std::fs::read(world.join("config/boot.json")).unwrap())
|
||||
.unwrap(),
|
||||
json!([])
|
||||
);
|
||||
for file in [
|
||||
"plugins/cargo/sdk/Cargo.toml",
|
||||
"plugins/cargo/sdk/src/lib.rs",
|
||||
"plugins/agent/source/Cargo.toml",
|
||||
"plugins/agent/source/src/main.rs",
|
||||
"plugins/agent/plugin.wasm",
|
||||
] {
|
||||
assert!(world.join(file).is_file(), "missing {file}");
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
|
||||
include_bytes!("../../../Cargo.lock")
|
||||
);
|
||||
assert!(!world.join("state/cargo/target").exists());
|
||||
assert!(
|
||||
Path::new(CACHE)
|
||||
.join("cargo-target/wasm32-wasip2/release")
|
||||
.is_dir()
|
||||
);
|
||||
let host = Host::start_with_cache(&world, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
|
||||
assert_eq!(doc["x-sausage-grace"], 600);
|
||||
assert!(
|
||||
doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|method| method["name"] == "chat")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
|
||||
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
|
||||
#[tokio::test]
|
||||
async fn headless_flag_and_environment() {
|
||||
for value in [None, Some("1"), Some("true")] {
|
||||
let (_folder, world) = world();
|
||||
let mut command = binary();
|
||||
command.arg("run").arg(&world).stdin(Stdio::null());
|
||||
if let Some(value) = value {
|
||||
command.env("SAUSAGE_HEADLESS", value);
|
||||
} else {
|
||||
command.arg("--headless");
|
||||
}
|
||||
let mut child = command.spawn().unwrap();
|
||||
journal_exists(&world).await;
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(300), child.wait())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
signal(&child, nix::sys::signal::Signal::SIGTERM);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
for value in ["0", ""] {
|
||||
let (_folder, world) = world();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(world)
|
||||
.env("SAUSAGE_HEADLESS", value)
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Signals interrupt an unanswered boot service instead of waiting forever.
|
||||
#[tokio::test]
|
||||
async fn signals_work_during_boot() {
|
||||
for sig in [
|
||||
nix::sys::signal::Signal::SIGTERM,
|
||||
nix::sys::signal::Signal::SIGINT,
|
||||
] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::write(
|
||||
world.join("config/boot.json"),
|
||||
json!([
|
||||
{"method":"core.lock","params":{"path":"boot"}},
|
||||
{"method":"core.lock","params":{"path":"boot"}}
|
||||
])
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.arg("--headless")
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
journal_exists(&world).await;
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
|
||||
if let Ok(2) = db.query_row(
|
||||
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, sig);
|
||||
assert_eq!(status(&mut child).await.code(), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Piped input supports commands, quit, and the restart exit code.
|
||||
#[tokio::test]
|
||||
async fn piped_console_and_exit_codes() {
|
||||
for (line, expected) in [("help\n", 0), ("quit\n", 0), ("core.exit\n", 75)] {
|
||||
let (_folder, world) = world();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(expected));
|
||||
if expected == 0 {
|
||||
assert!(!output.stdout.is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A console input error still stops live actors and flushes the journal.
|
||||
#[tokio::test]
|
||||
async fn console_error_stops_host() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
std::fs::write(
|
||||
world.join("config/boot.json"),
|
||||
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(&[255, 10])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status(&mut child).await.code(), Some(1));
|
||||
let db = rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
|
||||
let stopped: i64 = db
|
||||
.query_row(
|
||||
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(stopped > 0);
|
||||
}
|
||||
|
||||
/// SIGINT stops only the wait, so ps can still find the live call.
|
||||
#[tokio::test]
|
||||
async fn interrupt_leaves_call_running() {
|
||||
let (_folder, world) = world();
|
||||
install_probe(&world);
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut input = child.stdin.take().unwrap();
|
||||
input.write_all(b"probe.busy\n").await.unwrap();
|
||||
let pid = tokio::time::timeout(Duration::from_secs(20), async {
|
||||
loop {
|
||||
if world.join("state/core/journal.sqlite").exists() {
|
||||
let db =
|
||||
rusqlite::Connection::open(world.join("state/core/journal.sqlite")).unwrap();
|
||||
if let Ok(pid) = db.query_row(
|
||||
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
|
||||
[],
|
||||
|r| r.get::<_, String>(0),
|
||||
) {
|
||||
break pid;
|
||||
}
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
signal(&child, nix::sys::signal::Signal::SIGINT);
|
||||
input
|
||||
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
drop(input);
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let text = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(output.status.code(), Some(0), "{text}");
|
||||
assert!(
|
||||
text.contains("stopped waiting; the call continues, see ps"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("{pid} probe busy console running")),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Piped calls wait for activation, and EOF or quit preserves the last result.
|
||||
#[tokio::test]
|
||||
async fn piped_console_runs_dependent_commands_in_order() {
|
||||
for ending in ["", "quit\n"] {
|
||||
let (_folder, world) = world();
|
||||
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
|
||||
std::fs::create_dir_all(world.join("state/probe")).unwrap();
|
||||
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
|
||||
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
|
||||
let call = r#"notes.echo {"ordered":true}"#;
|
||||
let mut child = binary()
|
||||
.arg("run")
|
||||
.arg(&world)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.unwrap()
|
||||
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8(output.stdout).unwrap();
|
||||
assert_eq!(
|
||||
output.status.code(),
|
||||
Some(0),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(!stdout.contains("error:"), "{stdout}");
|
||||
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
|
||||
assert!(!stdout.contains("> core.activate"), "{stdout}");
|
||||
if !ending.is_empty() {
|
||||
assert!(
|
||||
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
|
||||
"{stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Persistent caches and guest builds shared by the host integration modules.
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::OnceLock,
|
||||
};
|
||||
|
||||
pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
|
||||
|
||||
pub fn build(package: &str, binary: &str) -> PathBuf {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
|
||||
let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
|
||||
assert!(
|
||||
std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path"
|
||||
])
|
||||
.arg(root.join(package).join("Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
target
|
||||
.join("wasm32-wasip2/debug")
|
||||
.join(format!("{binary}.wasm"))
|
||||
}
|
||||
|
||||
pub fn probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn sdk_probe() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe"))
|
||||
.clone()
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Console command contracts against a temporary world and the raw probe component.
|
||||
use super::common::probe as fixture;
|
||||
use sausage::{
|
||||
Host,
|
||||
console::{execute_line, notification_text},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
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();
|
||||
let mut candidate = std::fs::read(fixture()).unwrap();
|
||||
// A custom section creates a new version without changing probe behavior.
|
||||
candidate.extend_from_slice(&[0, 2, 1, b'c']);
|
||||
std::fs::write(root.join("candidate.wasm"), candidate).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
let cache = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/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_eq!(
|
||||
serde_json::from_str::<Value>(&command(&host, r#"agent.chat {"text":"explicit"}"#).await)
|
||||
.unwrap(),
|
||||
json!({"text":"explicit"})
|
||||
);
|
||||
assert_eq!(
|
||||
command(&host, "probe.echo not-json").await,
|
||||
"probe.echo not-json"
|
||||
);
|
||||
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;
|
||||
}
|
||||
/// Invalid command arguments and dotted prose remain agent chat.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn prose_is_not_a_command() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
for line in [
|
||||
"help me write a notes plugin",
|
||||
"ps is useful",
|
||||
"stop it",
|
||||
"kill that",
|
||||
"cat",
|
||||
"methods probe extra",
|
||||
"ls too many words",
|
||||
"log -n nope",
|
||||
"README.md says hello",
|
||||
"agent.chat not-json",
|
||||
"e.g. write notes",
|
||||
] {
|
||||
assert_eq!(command(&host, line).await, line);
|
||||
}
|
||||
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 formats local times and short pids, while core.log preserves JSON and filters.
|
||||
#[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();
|
||||
// Finish discovery before comparing two snapshots of the journal.
|
||||
command(&host, "methods data-logger").await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for (line, filters) in [
|
||||
("log".to_owned(), json!({})),
|
||||
(
|
||||
"log data-logger -n 1".to_owned(),
|
||||
json!({"plugin":"data-logger","limit":1}),
|
||||
),
|
||||
(
|
||||
"log probe -n 1".to_owned(),
|
||||
json!({"plugin":"probe","limit":1}),
|
||||
),
|
||||
(
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
json!({"pid":pid,"limit":1}),
|
||||
),
|
||||
] {
|
||||
let raw = command(&host, &format!("core.log {filters}")).await;
|
||||
let entries: Value = serde_json::from_str(&raw).unwrap();
|
||||
let entries = entries.as_array().unwrap();
|
||||
assert!(!entries.is_empty());
|
||||
let printed = command(&host, &line).await;
|
||||
let lines: Vec<_> = printed.lines().collect();
|
||||
assert_eq!(lines.len(), entries.len(), "{printed}");
|
||||
if line != "log" {
|
||||
assert_eq!(entries.len(), 1);
|
||||
}
|
||||
for (line, entry) in lines.into_iter().zip(entries) {
|
||||
let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.with_timezone(&chrono::Local)
|
||||
.format("%H:%M:%S%.3f")
|
||||
.to_string();
|
||||
let plugin = entry["plugin"].as_str().unwrap();
|
||||
let pid = entry["pid"].as_str().unwrap();
|
||||
let prefix = format!("{plugin}-");
|
||||
let short = pid
|
||||
.strip_prefix(&prefix)
|
||||
.map(|uuid| format!("{prefix}{}", &uuid[..8]))
|
||||
.unwrap_or_else(|| pid.to_owned());
|
||||
assert_eq!(
|
||||
line,
|
||||
format!(
|
||||
"{} {} {} {}",
|
||||
time,
|
||||
short,
|
||||
entry["stream"].as_str().unwrap(),
|
||||
entry["line"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(command(&host, "log absent-plugin").await, "");
|
||||
assert_eq!(command(&host, "log -n").await, "log -n");
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
||||
//! Host integration contracts run in one parallel libtest pool.
|
||||
mod activation;
|
||||
mod agent;
|
||||
mod cargo;
|
||||
mod cli;
|
||||
mod common;
|
||||
mod console;
|
||||
mod core;
|
||||
mod sdk;
|
||||
@@ -0,0 +1,349 @@
|
||||
//! 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(|| {
|
||||
for (package, binary) in [
|
||||
("examples/notes", "notes"),
|
||||
("examples/consumer", "consumer"),
|
||||
("examples/www", "www"),
|
||||
("examples/secrets", "secrets"),
|
||||
("sdk/tests/plugins/consumer", "sdk-consumer"),
|
||||
] {
|
||||
super::common::build(package, binary);
|
||||
}
|
||||
super::common::sdk_probe().parent().unwrap().to_owned()
|
||||
})
|
||||
}
|
||||
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"));
|
||||
}
|
||||
Reference in New Issue
Block a user