//! 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)> { 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())) } #[test] fn page_rendering() -> anyhow::Result<()> { let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland/chat"); let page = std::fs::read_to_string(source.join("index.html"))?; let script = page .split_once("") .unwrap() .0; let dir = tempfile::tempdir()?; let extracted = dir.path().join("chat.js"); std::fs::write(&extracted, script)?; let fixture = source.join("render.test.cjs"); for args in [ [Path::new("--check"), extracted.as_path()], [fixture.as_path(), extracted.as_path()], ] { let output = std::process::Command::new("node").args(args).output()?; anyhow::ensure!( output.status.success(), "Page script: {}", String::from_utf8_lossy(&output.stderr) ); } Ok(()) } #[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(()) }