cargo.build, the WASI test runner, the console, and core.notify ordering
- cargo.build (native): one source copy, tests under WASI, release build to /state/cargo/builds/<n>/, process groups killed on cancel. The host writes the SDK copy to /plugins/cargo/sdk/ at start. - sausage test-runner: one instance per test, in parallel, with its own mount table (empty world, package folder at its path); answers core.lock, core.unlock, core.notify, core.log. - The console: rustyline, notifications while typing, ls/cat through the process view, methods, ps, log, activate, revert, stop, kill. - core.notify on stdout is handled in order, before the response.
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
//! 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/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();
|
||||
std::fs::copy(fixture(), root.join("candidate.wasm")).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 supports plugin filters, process filters, and a row count.
|
||||
#[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 filtered: Value =
|
||||
serde_json::from_str(&command(&host, "log data-logger -n 1").await).unwrap();
|
||||
assert_eq!(filtered[0]["plugin"], "data-logger");
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for line in [
|
||||
"log".to_owned(),
|
||||
"log probe -n 1".to_owned(),
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
] {
|
||||
let entries: Value = serde_json::from_str(&command(&host, &line).await).unwrap();
|
||||
assert!(!entries.as_array().unwrap().is_empty());
|
||||
if line != "log" {
|
||||
assert_eq!(entries.as_array().unwrap().len(), 1);
|
||||
assert_eq!(entries[0]["plugin"], "probe");
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user