Files
sausage/host/tests/automations.rs
T

275 lines
8.7 KiB
Rust

//! World-loaded automation and pause cases, plus durable fire recovery after SIGKILL.
use anyhow::{Result, ensure};
use sausage::world::World;
use std::{
path::Path,
process::Command,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../js/automations-test-bundle.js");
fn copy_tree(source: &Path, destination: &Path) -> Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &destination.join(entry.file_name()))?;
} else if entry.path().extension().is_some_and(|ext| ext == "ts") {
std::fs::copy(entry.path(), destination.join(entry.file_name()))?;
}
}
Ok(())
}
fn setup(directory: &Path) -> Result<()> {
let world = World::open(directory)?;
let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap();
if !userland.join("cwd-init/init.o").is_file() {
ensure!(
Command::new("nu")
.args(["--no-config-file", "cwd-init/build.nu"])
.current_dir(&userland)
.status()?
.success(),
"cwd build failed"
);
}
ensure!(
Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"git",
"-p",
"curl",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(target)
.args(["-j", "2"])
.current_dir(&userland)
.status()?
.success(),
"guest build failed"
);
let release = target.join("wasm32-wasip2/release");
if !release.join("brush.wasm").is_file() {
ensure!(
Command::new("nu")
.arg("brush/build.nu")
.current_dir(&userland)
.status()?
.success(),
"shell build failed"
);
}
let disk = directory.join("world");
std::fs::create_dir_all(disk.join("bin"))?;
for (source, name) in [
("git.wasm", "git.wasm"),
("curl.wasm", "curl.wasm"),
("brush.wasm", "sh.wasm"),
] {
std::fs::copy(release.join(source), disk.join("bin").join(name))?;
}
let repo = disk.join("src/sausage");
std::fs::create_dir_all(&repo)?;
ensure!(
Command::new("git")
.args(["init", "-b", "main"])
.arg(&repo)
.status()?
.success()
);
copy_tree(
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"),
&repo.join("extensions"),
)?;
std::fs::create_dir_all(repo.join("agents"))?;
std::fs::write(
repo.join("agents/models.json"),
r#"{"default":{"model":"faux/faux-1"}}"#,
)?;
ensure!(
Command::new("git")
.args(["add", "."])
.current_dir(&repo)
.status()?
.success()
);
ensure!(
Command::new("git")
.args([
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"--no-gpg-sign",
"-m",
"automations fixture"
])
.current_dir(&repo)
.status()?
.success()
);
drop(world);
Ok(())
}
async fn run(directory: &Path, mode: &str) -> Result<()> {
let world = World::open(directory)?;
let exec = sausage::process::exec(World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
})?;
sausage::harness::run_module(
world,
exec,
&format!("globalThis.automationMode={mode:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn schedules_and_pause() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "cases").await
}
#[tokio::test(flavor = "current_thread")]
async fn event_and_context() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
run(directory.path(), "triggers").await
}
#[tokio::test(flavor = "current_thread")]
async fn script_inherits_creator_grant() -> Result<()> {
use sausage::{
api::Api,
cred::{Caller, Cred, grants::Grant, types::Subject},
};
use std::sync::Arc;
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let world = World::open(directory.path())?;
let server = Arc::new(
tiny_http::Server::http("127.0.0.1:0")
.map_err(|_| anyhow::anyhow!("fixture bind failed"))?,
);
let port = server.server_addr().to_ip().unwrap().port();
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller::default();
let token = uuid::Uuid::new_v4().to_string();
let item = cred.create_item(serde_json::from_value(serde_json::json!({"name":"automation fixture","rules":[{"scheme":"http","hosts":["127.0.0.1"],"port":port,"path_prefixes":["/token"]}],"transform_js":sausage::cred::summary::script("bearer")?,"fields":[{"name":"token","field_type":"text"}]}))?, &caller, cred.unlocked()?)?;
cred.set_field(
&item.id,
&item.fields[0].field_id,
&token,
&caller,
cred.unlocked()?,
)?;
cred.grant_create(
Grant {
id: String::new(),
items: vec![item.id],
subject: Subject::Conversation("1".into()),
exp: None,
max_uses: None,
uses: 0,
revoked: false,
validator: None,
note: None,
},
&caller,
cred.unlocked()?,
)?;
let api = Arc::new(Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8081",
)?);
let runtime = Arc::new(sausage::process::Runtime::new_with_hooks(
World {
root: world.root.clone(),
host_dir: world.host_dir.clone(),
processes: world.processes.clone(),
},
cred.clone(),
sausage::process::default_script(),
Some(api),
)?);
let serving = server.clone();
let expected = format!("Bearer {token}");
let thread = std::thread::spawn(move || {
let mut accepted = 0;
while let Ok(request) = serving.recv() {
let valid = request.headers().iter().any(|header| {
header.field.equiv("Authorization") && header.value.as_str() == expected
});
if valid {
accepted += 1;
}
let _ = request.respond(tiny_http::Response::from_string(if valid {
"accepted"
} else {
"missing"
}));
}
accepted
});
let source =
format!("globalThis.automationMode='grant'; globalThis.automationPort={port};\n{BUNDLE}");
let result = tokio::time::timeout(
Duration::from_secs(60),
sausage::harness::run_module_with_credentials(world, runtime, cred, &source),
)
.await;
server.unblock();
let accepted = thread.join().unwrap();
result??;
ensure!(
accepted == 1,
"expected one injected request, got {accepted}"
);
Ok(())
}
#[test]
fn automations_child() -> Result<()> {
let Some(directory) = std::env::var_os("SAUSAGE_AUTOMATIONS_CHILD") else {
return Ok(());
};
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(run(Path::new(&directory), "prepare"))
}
#[tokio::test(flavor = "current_thread")]
async fn fire_after_sigkill() -> Result<()> {
let directory = tempfile::tempdir()?;
setup(directory.path())?;
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "automations_child", "--nocapture"])
.env("SAUSAGE_AUTOMATIONS_CHILD", directory.path())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(60);
let ready = directory.path().join("world/automation-ready");
while !ready.exists() {
if let Some(status) = child.try_wait()? {
anyhow::bail!("child ended before ready: {status}");
}
if Instant::now() >= deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("child readiness deadline");
}
tokio::task::yield_now().await;
}
child.kill()?;
child.wait()?;
run(directory.path(), "restart").await?;
run(directory.path(), "restart").await
}