Files
sausage/cli/tests/cli.rs
T
surma a090e1fd57 host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule
cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
2026-10-23 07:47:39 +01:00

349 lines
11 KiB
Rust

//! 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,
sync::OnceLock,
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::{Child, Command},
};
fn binary() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
command.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 probe() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = root.join("target/core/console-guests");
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.status()
.unwrap()
.success()
);
target.join("wasm32-wasip2/debug/probe.wasm")
})
.clone()
}
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")
);
let host = Host::start_with_cache(
&world,
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/core/cli-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);
}
/// Ps and kill remain available before a slow command returns.
#[tokio::test]
async fn piped_console_accepts_commands_during_a_call() {
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();
let mut output = BufReader::new(child.stdout.take().unwrap()).lines();
input.write_all(b"probe.busy\n").await.unwrap();
let mut pid = None;
tokio::time::timeout(Duration::from_secs(15), async {
while pid.is_none() {
input.write_all(b"ps\n").await.unwrap();
while let Some(line) = output.next_line().await.unwrap() {
if line.contains("probe busy console") {
pid = line.split_whitespace().next().map(str::to_owned);
break;
}
if line.contains("PID PLUGIN METHOD") {
break;
}
}
}
})
.await
.unwrap();
input
.write_all(format!("kill {}\n", pid.unwrap()).as_bytes())
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(15), async {
while let Some(line) = output.next_line().await.unwrap() {
if line.starts_with("> kill ") {
break;
}
}
})
.await
.unwrap();
input.write_all(b"quit\n").await.unwrap();
assert_eq!(status(&mut child).await.code(), Some(0));
}