//! 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() { crate::common::cwd_init(); } 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!( crate::common::build_brush()?.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::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 }