//! Console command contracts against a temporary world and the raw probe component. use sausage::{ Host, console::{execute_line, notification_text}, world::World, }; use serde_json::{Value, json}; use std::{path::PathBuf, sync::OnceLock}; fn fixture() -> PathBuf { static WASM: OnceLock = OnceLock::new(); WASM.get_or_init(|| { let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .to_owned(); let target = root.join("target/vfs/console-guests"); let status = std::process::Command::new("cargo") .args([ "build", "--offline", "--target", "wasm32-wasip2", "--manifest-path", ]) .arg(root.join("cli/tests/plugins/probe/Cargo.toml")) .arg("--target-dir") .arg(&target) .status() .unwrap(); assert!(status.success()); target.join("wasm32-wasip2/debug/probe.wasm") }) .clone() } struct TestWorld(PathBuf); impl TestWorld { fn new() -> Self { let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7())); let world = World::create(&root).unwrap(); for plugin in ["probe", "agent"] { std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap(); std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap(); } std::fs::create_dir_all(world.state_dir("probe")).unwrap(); std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap(); 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(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("target/vfs/console-cache"); Host::start_with_cache(&self.0, &cache).await.unwrap() } } impl Drop for TestWorld { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } async fn command(host: &Host, line: &str) -> String { tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line)) .await .unwrap() .unwrap() } /// Explicit calls return indented JSON data, and RPC errors remain visible. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn rpc_call() { let world = TestWorld::new(); let host = world.start().await; let result = command(&host, "probe.echo {\"answer\":42}").await; assert!(result.contains("\n \"answer\": 42\n")); assert!(command(&host, "absent.echo {}").await.contains("error:")); assert!(execute_line(&host, "probe.echo not-json").await.is_err()); host.stop().await; } /// Plain text calls agent.chat and its interim notification uses dim styling. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn chat() { let world = TestWorld::new(); let host = world.start().await; let mut notifications = host.subscribe(); assert_eq!(command(&host, "hello agent").await, "hello agent"); let value = notifications.recv().await.unwrap(); assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m"); assert_eq!( command(&host, "Hello. Another sentence.").await, "Hello. Another sentence." ); host.stop().await; } /// Folder listings include the live mounts and accept an explicit folder. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ls() { let world = TestWorld::new(); let host = world.start().await; let result = command(&host, "ls").await; for name in ["proc/", "run/", "self/"] { assert!(result.contains(name)); } assert!(command(&host, "ls /proc/core").await.contains("plugins/")); host.stop().await; } /// Cat reads multiple chunks and finite generated snapshots through the VFS. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cat() { let world = TestWorld::new(); let host = world.start().await; let text = "x".repeat(150_000); std::fs::write(world.0.join("large"), &text).unwrap(); assert_eq!(command(&host, "cat /large").await, text); host.call("probe.write", json!({"path":"/run/shared","text":"memory"})) .await .unwrap(); assert_eq!(command(&host, "cat /run/shared").await, "memory"); assert!( command(&host, "cat /proc/core/PROTOCOL.md") .await .contains("filesystem") ); assert!(execute_line(&host, "cat /missing").await.is_err()); host.stop().await; } /// Method summaries include parameter types, result types, and documentation. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn methods() { let world = TestWorld::new(); let host = world.start().await; let result = command(&host, "methods probe").await; for text in [ "probe.echo(text: string) -> string", "Echo supplied text.", "The input text.", ] { assert!(result.contains(text), "{result}"); } host.stop().await; } /// Ps includes every requested column and the live process metadata. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ps() { let world = TestWorld::new(); let host = world.start().await; let pid = host.call("probe.serve", json!({})).await.unwrap(); let text = command(&host, "ps").await; assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE")); assert!(text.contains(pid.as_str().unwrap())); assert!(text.contains("probe serve console")); host.stop().await; } /// Log 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(); 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!(execute_line(&host, "log -n").await.is_err()); host.stop().await; } /// Activate installs a candidate through core.activate. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn activate() { let world = TestWorld::new(); let host = world.start().await; let result = command(&host, "activate probe candidate.wasm").await; assert!(!result.starts_with("error:"), "{result}"); assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists()); host.stop().await; } /// Revert restores the previous component through core.revert. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn revert() { let world = TestWorld::new(); let host = world.start().await; command(&host, "activate probe candidate.wasm").await; let result = command(&host, "revert probe").await; assert!(!result.starts_with("error:"), "{result}"); host.stop().await; } /// Stop closes a live process gracefully. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn stop() { let world = TestWorld::new(); let host = world.start().await; let pid = host.call("probe.serve", json!({})).await.unwrap(); command(&host, &format!("stop {}", pid.as_str().unwrap())).await; assert!(!host.processes().contains_key(pid.as_str().unwrap())); host.stop().await; } /// Kill ends a live process immediately. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn kill() { let world = TestWorld::new(); let host = world.start().await; let pid = host.call("probe.serve", json!({})).await.unwrap(); command(&host, &format!("kill {}", pid.as_str().unwrap())).await; assert!(!host.processes().contains_key(pid.as_str().unwrap())); host.stop().await; } /// Help lists all console commands. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn help() { let world = TestWorld::new(); let host = world.start().await; let text = command(&host, "help").await; for name in [ "agent", "plugin.method", "ls", "cat", "methods", "ps", "log", "activate", "revert", "stop", "kill", "help", "quit", ] { assert!(text.contains(name)); } host.stop().await; } /// Quit produces a final message, and blank input does nothing. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn quit() { let world = TestWorld::new(); let host = world.start().await; assert_eq!(command(&host, "quit").await, "Goodbye."); assert_eq!(command(&host, " ").await, ""); host.stop().await; }