//! 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}, }; const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"); 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 probe() -> PathBuf { static WASM: OnceLock = 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::(&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); } /// 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)); }