Files
sausage/cli/tests/console.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

320 lines
12 KiB
Rust

//! 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<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_owned();
let target = root.join("target/core/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/core/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_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",
"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();
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;
}