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

286 lines
10 KiB
Rust

//! A fresh world's seeded init service serves chat and proxies the real harness API.
use sausage::{
cred::{
Caller, Cred,
items::{Item, Rule},
requests::GrantRequest,
},
harness::{
input::{self, Input},
run_module_with_input,
},
process::Runtime,
world::World,
};
use std::{path::Path, sync::Arc, time::Duration};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn request(port: u16, path: &str, body: Option<&str>) -> anyhow::Result<(u16, Vec<u8>)> {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
let method = if body.is_some() { "POST" } else { "GET" };
let body = body.unwrap_or("");
stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await?;
let mut bytes = Vec::new();
stream.read_to_end(&mut bytes).await?;
let end = bytes
.windows(4)
.position(|s| s == b"\r\n\r\n")
.ok_or_else(|| anyhow::anyhow!("Missing HTTP headers"))?
+ 4;
let header = std::str::from_utf8(&bytes[..end])?;
let status = header.split_whitespace().nth(1).unwrap().parse()?;
Ok((status, bytes[end..].to_vec()))
}
#[tokio::test(flavor = "current_thread")]
async fn seeded_chat() -> anyhow::Result<()> {
seeded_chat_case(false).await
}
#[tokio::test(flavor = "current_thread")]
async fn shutdown_with_open_connection() -> anyhow::Result<()> {
seeded_chat_case(true).await
}
async fn seeded_chat_case(open_connection: bool) -> anyhow::Result<()> {
let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let target = crate::common::target_dir();
crate::common::cwd_init();
anyhow::ensure!(
std::process::Command::new("cargo")
.args([
"build",
"--release",
"--locked",
"-p",
"sausage-chat",
"-p",
"sausage-init",
"--target",
"wasm32-wasip2",
"--target-dir"
])
.arg(&target)
.current_dir(source.join("userland"))
.status()?
.success(),
"Chat guest build failed"
);
let bundle = tempfile::tempdir()?;
let units = bundle.path().join("etc/systemd/system");
std::fs::create_dir_all(units.join("multi-user.target.wants"))?;
std::fs::copy(
source.join("userland/chat/chat.service"),
units.join("chat.service"),
)?;
std::fs::write(units.join("multi-user.target.wants/chat.service"), "")?;
std::fs::create_dir_all(bundle.path().join("bin"))?;
for binary in ["chat", "init"] {
std::fs::copy(
target.join(format!("wasm32-wasip2/release/{binary}.wasm")),
bundle.path().join(format!("bin/{binary}.wasm")),
)?;
}
crate::common::copy_tree(
&source.join("userland/chat"),
&bundle.path().join("src/sausage/userland/chat"),
)?;
let dir = tempfile::tempdir()?;
drop(World::open_with_userland(dir.path(), Some(bundle.path()))?);
let port = std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port();
let seeded = dir.path().join("world/etc/systemd/system/chat.service");
let unit = std::fs::read_to_string(&seeded)?.replace(
"ExecStart=/bin/chat.wasm",
&format!("ExecStart=/bin/chat.wasm --listen 127.0.0.1:{port}"),
);
std::fs::write(&seeded, &unit)?;
let world = World::open_with_userland(dir.path(), Some(bundle.path()))?;
assert_eq!(
std::fs::read_to_string(&seeded)?,
unit,
"Reopen must not replace a world's unit"
);
assert!(
dir.path()
.join("world/src/sausage/userland/chat/Cargo.toml")
.is_file()
);
let cred = Arc::new(Cred::open(&world.host_dir)?);
let caller = Caller {
program: "/bin/chat.wasm".into(),
pid: 1,
conversations: vec!["1".into()],
};
let item = cred.create_item(
Item {
id: String::new(),
name: "Chat test credential".into(),
version: 0,
free_use: false,
rules: vec![Rule {
port: None,
scheme: "https".into(),
hosts: vec!["example.test".into()],
path_prefixes: vec!["/".into()],
methods: None,
}],
validate_js: None,
transform_js: String::new(),
fields: vec![],
},
&caller,
cred.unlocked()?,
)?;
let pending = cred.file_request(
&caller,
"chat-test",
GrantRequest {
items: vec![item.id],
duration_secs: None,
max_uses: None,
validator: None,
},
)?;
let api = Arc::new(sausage::api::Api::open(
cred.clone(),
&world.host_dir,
"localhost",
"http://localhost:8090",
)?);
let (sender, receiver) = input::channel();
api.attach_harness(sender.clone());
let runtime = 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 services = sausage::process::supervise(runtime.clone());
let (library, _) = include_str!("../../js/harness-api-test-bundle.js")
.split_once("// harness-api-test.ts")
.unwrap();
let script = format!(
r#"{library}
const chatModels=createModels(),chatFaux=fauxProvider({{tokensPerSecond:100000}});
chatModels.setProvider(chatFaux.provider);chatFaux.setResponses([fauxAssistantMessage('Chat test reply')]);
const chatRegistry=createRegistry(),chatStorage=await openStorage(globalThis.storagePath);
const chatHarness=await Harness.open(chatStorage,{{models:chatModels,registry:chatRegistry}},BACKGROUND_CONTEXT);
await chatHarness.root(BACKGROUND_CONTEXT,{{agent:{{model:{{provider:'faux',modelId:'faux-1'}}}}}});
const chatApi=harnessApi(chatHarness,chatModels,{{state:()=>({{}})}},BACKGROUND_CONTEXT);
chatHarness.resume();await serve(chatHarness,BACKGROUND_CONTEXT,undefined,chatApi);
await chatApi.close();await chatHarness.close(BACKGROUND_CONTEXT);
"#
);
let client = async {
let deadline = std::time::Instant::now() + Duration::from_secs(60);
let page = loop {
if let Ok((200, body)) = request(port, "/", None).await {
break body;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Init did not start chat"
);
tokio::task::yield_now().await;
};
assert!(String::from_utf8_lossy(&page).contains("Sausage chat"));
let root = loop {
let (status, body) = request(port, "/api/conversations", None).await?;
let value: serde_json::Value = serde_json::from_slice(&body)?;
if status == 200
&& let Some(id) = value["conversations"][0]["id"].as_u64()
{
break id;
}
anyhow::ensure!(
std::time::Instant::now() < deadline,
"Harness was not ready"
);
tokio::task::yield_now().await;
};
let submit = serde_json::json!({"content":"Chat test input","requestId":"chat-stable-id"})
.to_string();
for _ in 0..2 {
assert_eq!(
request(
port,
&format!("/api/conversations/{root}/submit"),
Some(&submit)
)
.await?
.0,
202
);
}
loop {
let (status, body) = request(
port,
&format!("/api/conversations/{root}/entries?after=0"),
None,
)
.await?;
assert_eq!(status, 200);
if String::from_utf8_lossy(&body).contains("Chat test reply") {
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert_eq!(
value["entries"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["kind"] == "pi.user")
.count(),
1
);
break;
}
anyhow::ensure!(std::time::Instant::now() < deadline, "Reply did not appear");
tokio::task::yield_now().await;
}
let (status, body) = request(port, "/api/credential-requests", None).await?;
assert_eq!(status, 200);
let value: serde_json::Value = serde_json::from_slice(&body)?;
assert!(
value["requests"]
.as_array()
.unwrap()
.iter()
.any(|r| r["id"] == pending
&& r["approvalUrl"] == format!("http://localhost:8090/approve/{pending}"))
);
assert_eq!(request(port, "/api/unknown", None).await?.0, 404);
sender.send(Input::Stop)?;
Ok::<_, anyhow::Error>(())
};
let result = tokio::time::timeout(Duration::from_secs(90), async {
tokio::try_join!(
run_module_with_input(world, Arc::new(runtime.clone()), cred, receiver, &script),
client
)
})
.await;
let connection = if open_connection {
let mut stream = tokio::net::TcpStream::connect(("127.0.0.1", port)).await?;
// Leave the request incomplete so the server must wait for more input.
stream
.write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n")
.await?;
Some(stream)
} else {
None
};
let started = std::time::Instant::now();
services.shutdown().await;
let elapsed = started.elapsed();
drop(connection);
result??;
println!("Chat shutdown (open_connection={open_connection}): {elapsed:?}");
anyhow::ensure!(
elapsed < Duration::from_secs(3),
"Chat delayed shutdown: {elapsed:?}"
);
Ok(())
}