Files
sausage/host/tests/it/harness_foundation.rs
T

216 lines
6.5 KiB
Rust

//! Patched pi-durable input placement, hold boundaries, and headless channel lifecycle.
use sausage::{
cred::Cred,
harness::{
input::{self, Input},
run_module, run_module_with_input,
},
world::World,
};
use std::{
process::{Command, Stdio},
sync::Arc,
time::{Duration, Instant},
};
const BUNDLE: &str = include_str!("../../js/foundation-test-bundle.js");
#[test]
fn bundle_has_patched_hooks() {
for bundle in [BUNDLE, include_str!("../../js/bundle.js")] {
assert!(bundle.contains("handlers.beforeInput"));
assert!(bundle.contains("input: true"));
assert!(bundle.contains("#held"));
}
assert!(!include_str!("../../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node"));
}
async fn probe(case: &str) -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let exec = sausage::process::exec(World::open(dir.path())?)?;
run_module(
world,
exec,
&format!("globalThis.foundationCase = {case:?};\n{BUNDLE}"),
)
.await
}
#[tokio::test(flavor = "current_thread")]
async fn input_placement() -> anyhow::Result<()> {
probe("placement").await
}
#[tokio::test(flavor = "current_thread")]
async fn followups_start_one_run() -> anyhow::Result<()> {
probe("followups").await
}
#[tokio::test(flavor = "current_thread")]
async fn response_thinking_level() -> anyhow::Result<()> {
probe("thinking").await
}
#[tokio::test(flavor = "current_thread")]
async fn hold_parks_at_phase_boundary() -> anyhow::Result<()> {
probe("hold").await
}
#[tokio::test(flavor = "current_thread")]
async fn headless_child() -> anyhow::Result<()> {
if std::env::var_os("HEADLESS_CHILD").is_none() {
return Ok(());
}
let dir = tempfile::tempdir()?;
let world = World::open(dir.path())?;
let cred = Arc::new(Cred::open(&world.host_dir)?);
let exec = sausage::process::exec(World::open(dir.path())?)?;
let (sender, receiver) = input::channel();
sender.send(Input::Submit {
conversation: 1,
content: serde_json::json!("channel input"),
request_id: "channel-request".into(),
})?;
sender.send(Input::Stop)?;
tokio::time::timeout(
Duration::from_secs(30),
run_module_with_input(
world,
exec,
cred,
receiver,
&format!("globalThis.foundationCase = 'headless';\n{BUNDLE}"),
),
)
.await??;
Ok(())
}
#[test]
fn channel_with_closed_stdin() -> anyhow::Result<()> {
let mut child = Command::new(std::env::current_exe()?)
.args([
"--exact",
"harness_foundation::headless_child",
"--nocapture",
])
.env("HEADLESS_CHILD", "1")
.stdin(Stdio::null())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(40);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("Headless channel did not stop");
}
std::thread::yield_now();
}
Ok(())
}
#[test]
fn cli_stays_up_after_stdin_eof() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
World::open(dir.path())?;
let disk = dir.path().join("world");
let target = crate::common::build_git();
std::fs::create_dir_all(disk.join("bin"))?;
for binary in ["git", "tar"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
disk.join(format!("bin/{binary}.wasm")),
)?;
}
let repo = disk.join("src/sausage");
let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions");
for extension in ["core", "models", "subagents"] {
crate::common::copy_tree(
&source.join(extension),
&repo.join("extensions").join(extension),
)?;
}
crate::common::write_config(
dir.path(),
"console",
serde_json::json!([
{"name":"console","description":"Standing console","model":"openai/gpt-6.1-sol","prompt":"Keep the user conversation"}
]),
)?;
for args in [
vec!["init", "-b", "main"],
vec!["add", "."],
vec!["commit", "--no-gpg-sign", "-m", "EOF fixture"],
] {
assert!(
Command::new("git")
.args(args)
.current_dir(&repo)
.env("GIT_AUTHOR_NAME", "Test")
.env("GIT_AUTHOR_EMAIL", "test@example.com")
.env("GIT_COMMITTER_NAME", "Test")
.env("GIT_COMMITTER_EMAIL", "test@example.com")
.status()?
.success()
);
}
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("run")
.arg(dir.path())
.env_remove("SAUSAGE_USERLAND")
.stdin(Stdio::null())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
if let Some(status) = child.try_wait()? {
anyhow::bail!("CLI exited at EOF: {status}");
}
if let Ok(db) = rusqlite::Connection::open(dir.path().join("host/harness.sqlite"))
&& db
.query_row("SELECT count(*) FROM conversations", [], |r| {
r.get::<_, i64>(0)
})
.unwrap_or(0)
> 1
{
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("CLI did not initialize");
}
std::thread::yield_now();
}
// EOF shutdown previously took one credential tick. Check beyond that tick.
let alive_until = Instant::now() + Duration::from_secs(2);
while Instant::now() < alive_until {
if let Some(status) = child.try_wait()? {
anyhow::bail!("CLI exited after EOF: {status}");
}
std::thread::yield_now();
}
assert!(
Command::new("kill")
.args(["-TERM", &child.id().to_string()])
.status()?
.success()
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait()? {
assert!(status.success());
break;
}
if Instant::now() > deadline {
child.kill()?;
child.wait()?;
anyhow::bail!("CLI did not stop cleanly");
}
std::thread::yield_now();
}
Ok(())
}