#![allow(dead_code)]
use std::collections::HashMap;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use capsule_runtime::MUR_BINARY_ENV;
use mur_roost::{authority::SpawnAuthority, JobStatus, State};
use murmur_artifact::{ArtifactMeta, LocalRegistry, Registry, RuntimeType};
use serde_json::{json, Value};
use tempfile::TempDir;
pub fn fixture_path(relative: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("murmur-cli")
.join("tests")
.join("fixtures")
.join(relative)
}
pub fn component(name: &str) -> PathBuf {
fixture_path(&format!("run/components/{name}"))
}
pub fn mur_binary() -> &'static Path {
static BINARY: OnceLock<PathBuf> = OnceLock::new();
BINARY.get_or_init(|| {
if let Some(path) = std::env::var_os(MUR_BINARY_ENV) {
return PathBuf::from(path);
}
let profile_dir = std::env::current_exe()
.expect("the test binary has a path")
.parent()
.and_then(Path::parent)
.expect("the test binary lives under target/<profile>/deps")
.to_path_buf();
let candidate = profile_dir.join("mur");
if !candidate.is_file() {
let status = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.args(["build", "-p", "murmur-cli", "--bin", "mur"])
.status()
.expect("cargo build -p murmur-cli must run");
assert!(status.success(), "failed to build the mur binary");
}
assert!(
candidate.is_file(),
"cargo built murmur-cli but {} is missing",
candidate.display()
);
candidate
})
}
pub fn publish_capsule(
registry_root: &Path,
name: &str,
version: &str,
manifest_body: &str,
component_path: Option<&Path>,
) {
let mut cursor = Cursor::new(Vec::<u8>::new());
{
let mut zip = zip::ZipWriter::new(&mut cursor);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("murmur.yaml", options).unwrap();
zip.write_all(
format!(
"name: {}\nversion: {version}\n{manifest_body}",
murmur_artifact::tool_name_of(name)
)
.as_bytes(),
)
.unwrap();
if let Some(component_path) = component_path {
zip.start_file("capsule.wasm", options).unwrap();
zip.write_all(&std::fs::read(component_path).unwrap())
.unwrap();
}
zip.finish().unwrap();
}
publish_bytes(registry_root, name, version, &cursor.into_inner());
}
pub fn publish_driver(registry_root: &Path, name: &str, version: &str, wasm_path: &Path) {
let mut cursor = Cursor::new(Vec::<u8>::new());
{
let mut zip = zip::ZipWriter::new(&mut cursor);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
zip.start_file("murmur.yaml", options).unwrap();
zip.write_all(
format!(
"name: {}\nversion: {version}\nruntime: driver\n\
upstream_auth:\n header: x-api-key\n value: \"{{key}}\"\n\
models:\n test-model:\n max_output_tokens: 8192\n context_window: 1000000\n",
murmur_artifact::tool_name_of(name)
)
.as_bytes(),
)
.unwrap();
zip.start_file("tool.wasm", options).unwrap();
zip.write_all(&std::fs::read(wasm_path).unwrap()).unwrap();
zip.finish().unwrap();
}
publish_bytes(registry_root, name, version, &cursor.into_inner());
}
fn publish_bytes(registry_root: &Path, name: &str, version: &str, bytes: &[u8]) {
LocalRegistry::new(registry_root)
.publish(
ArtifactMeta {
name: murmur_artifact::ArtifactName::parse(name).unwrap(),
version: version.to_string(),
runtime: RuntimeType::Wasm,
artifact_runtime: "capsule".to_string(),
description: None,
tags: Vec::new(),
wit_contracts: None,
owner_id: None,
built_at_level: None,
},
bytes,
)
.unwrap();
}
pub struct Roost {
pub url: String,
pub registry: TempDir,
pub state: Arc<State>,
}
impl Roost {
pub fn start(spawn_allow: &[&str]) -> Self {
let registry = TempDir::new().unwrap();
let path = registry.path().to_path_buf();
Self::build(registry, path, spawn_allow)
}
pub fn start_at(registry_path: &Path, spawn_allow: &[&str]) -> Self {
Self::build(
TempDir::new().unwrap(),
registry_path.to_path_buf(),
spawn_allow,
)
}
fn build(registry: TempDir, registry_path: PathBuf, spawn_allow: &[&str]) -> Self {
let state = Arc::new(State {
jobs: Arc::new(Mutex::new(HashMap::new())),
registry_path,
spawn_allow: spawn_allow.iter().map(|name| name.to_string()).collect(),
max_depth: mur_roost::bounds::DEFAULT_MAX_DEPTH,
max_concurrent: u32::MAX,
max_live_capsules: u32::MAX,
inherited: Default::default(),
authority: Arc::new(SpawnAuthority::generate().unwrap()),
});
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let accept_state = Arc::clone(&state);
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let state = Arc::clone(&accept_state);
thread::spawn(move || mur_roost::handle_connection(stream, state));
}
});
Self {
url,
registry,
state,
}
}
pub fn registry_path(&self) -> &Path {
&self.state.registry_path
}
pub fn publish(&self, name: &str, version: &str, body: &str, component_path: Option<&Path>) {
publish_capsule(self.registry.path(), name, version, body, component_path);
}
pub fn register(&self, session_id: &str, name: &str, version: &str) -> String {
let response = self
.post(
"/register",
&json!({"session_id": session_id, "name": name, "version": version}).to_string(),
&[],
)
.expect("register must answer");
response["credential"]
.as_str()
.unwrap_or_else(|| panic!("register was refused: {response}"))
.to_string()
}
pub fn permission(&self, credential: &str, name: &str, version: &str) -> Value {
self.post(
"/spawn",
&json!({"name": name, "version": version}).to_string(),
&[(capsule_runtime::SPAWN_CREDENTIAL_HEADER, credential)],
)
.expect("spawn must answer")
}
pub fn status(&self, session_id: &str) -> Option<String> {
self.state
.jobs
.lock()
.unwrap()
.get(session_id)
.map(|job| match job.status {
JobStatus::Running => "running".to_string(),
JobStatus::Complete => "complete".to_string(),
JobStatus::Failed => "failed".to_string(),
})
}
pub fn status_over_http(&self, session_id: &str) -> Value {
self.get(&format!("/status/{session_id}"))
.expect("status must answer")
}
pub fn post(&self, path: &str, body: &str, headers: &[(&str, &str)]) -> Option<Value> {
request("POST", &format!("{}{path}", self.url), Some(body), headers)
}
pub fn get(&self, path: &str) -> Option<Value> {
request("GET", &format!("{}{path}", self.url), None, &[])
}
}
pub fn request(
method: &str,
url: &str,
body: Option<&str>,
headers: &[(&str, &str)],
) -> Option<Value> {
let rest = url.strip_prefix("http://")?;
let (authority, path) = match rest.find('/') {
Some(index) => (&rest[..index], &rest[index..]),
None => (rest, "/"),
};
let mut stream = TcpStream::connect(authority).ok()?;
stream
.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.ok()?;
let extra: String = headers
.iter()
.map(|(name, value)| format!("{name}: {value}\r\n"))
.collect();
let request = match body {
Some(body) => format!(
"POST {path} HTTP/1.1\r\nHost: {authority}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n{extra}Connection: close\r\n\r\n{body}",
body.len()
),
None => format!(
"{method} {path} HTTP/1.1\r\nHost: {authority}\r\n{extra}Connection: close\r\n\r\n"
),
};
stream.write_all(request.as_bytes()).ok()?;
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
let (_, body) = response.split_once("\r\n\r\n")?;
serde_json::from_str(body).ok()
}
pub struct ScriptedServer {
pub endpoint: String,
}
impl ScriptedServer {
pub fn always_replying(text: &str) -> Self {
Self::serving(json!({
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "test-model",
"content": [{"type": "text", "text": text}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1}
}))
}
pub fn always_answering(body: &str) -> Self {
Self::serving_body(body.to_string())
}
fn serving(response: serde_json::Value) -> Self {
Self::serving_body(response.to_string())
}
fn serving_body(body: String) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
thread::spawn(move || {
for stream in listener.incoming().flatten() {
let body = body.clone();
thread::spawn(move || answer(stream, &body));
}
});
Self { endpoint }
}
pub fn authority(&self) -> &str {
self.endpoint.trim_start_matches("http://")
}
}
fn answer(mut stream: TcpStream, body: &str) {
let mut buffer = [0u8; 65536];
let _ = stream.read(&mut buffer);
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
}
pub fn files_under(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else {
found.push(path);
}
}
}
found
}
pub fn find_in_files(root: &Path, needle: &str) -> Option<PathBuf> {
files_under(root).into_iter().find(|path| {
std::fs::read(path)
.map(|bytes| {
bytes
.windows(needle.len())
.any(|window| window == needle.as_bytes())
})
.unwrap_or(false)
})
}