use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use marbots_sdk::*;
struct Server {
child: Child,
data: PathBuf,
client: Client,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.data);
}
}
fn start() -> Option<Server> {
let dll = std::env::var("MARBOTS_SERVER_DLL")
.map(PathBuf::from)
.unwrap_or_else(|_| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../src/Marbots.Server/bin/Debug/net10.0/Marbots.Server.dll")
});
if !dll.exists() {
eprintln!("skipped: server not built at {}", dll.display());
return None;
}
let port = TcpListener::bind("127.0.0.1:0")
.ok()?
.local_addr()
.ok()?
.port();
let data = std::env::temp_dir().join(format!("mb-rs-{port}"));
let child = Command::new("dotnet")
.arg(&dll)
.args(["--urls", &format!("http://127.0.0.1:{port}")])
.current_dir(dll.parent()?)
.env("Marbots__DataDirectory", &data)
.env("Marbots__Providers__0__Name", "lab")
.env("Marbots__Providers__0__Kind", "mock")
.env("Marbots__Providers__0__Models__0", "lab-small")
.env("Marbots__Providers__0__Models__1", "lab-large")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
let client = Client::new(format!("http://127.0.0.1:{port}"));
let server = Server {
child,
data,
client,
};
for _ in 0..120 {
if server.client.system().is_ok() {
return Some(server);
}
thread::sleep(Duration::from_millis(500));
}
panic!("server did not start");
}
#[test]
fn conformance() {
let Some(server) = start() else { return };
let mb = &server.client;
let me = mb.tenancy().whoami().unwrap();
assert_eq!(
(me.tenant.as_str(), me.role, me.multi_tenant),
("default", TenantRole::Owner, false)
);
let sys = mb.system().unwrap();
assert_eq!(sys.product, "Marbots");
assert!(sys.credits_en.contains("Gravicode"));
assert!(mb
.bots()
.list()
.unwrap()
.iter()
.any(|b| b.id == BOSS_MAN && b.is_system));
let dina = mb.bots().hire("data-analyst", Some("Dina")).unwrap();
assert!(dina.uses_default_model());
assert!(dina.kernel_functions.contains(&KernelPack::Shell));
assert_eq!(dina.permission_profile, PermissionProfile::DeveloperSafe);
let info = mb
.bots()
.set_model(&dina.id, &ModelRef::of("lab", "lab-large"))
.unwrap();
assert_eq!(info.effective, "lab/lab-large");
assert!(!info.uses_default);
let err = mb
.bots()
.set_model(&dina.id, &ModelRef::of("ghost", "x"))
.unwrap_err();
assert_eq!(err.status(), Some(400));
let thread = mb.threads().create(&dina.id, None).unwrap();
let r = mb
.threads()
.send_with_timeout(&thread.id, "hello", true, 30)
.unwrap();
assert_eq!(r.task.state, TaskState::Completed);
assert_eq!(r.task.model.as_deref(), Some("lab/lab-large"));
assert!(r.text().contains("mock"));
let imported = mb
.bots()
.import_package(&mb.bots().export(&dina.id, false).unwrap())
.unwrap();
assert_eq!(imported.model, "lab/lab-large");
mb.bots().delete(&imported.id).unwrap();
mb.bots().delete(&dina.id).unwrap();
let typed = mb
.bots()
.create(
BotSpec::new("Typed Ferris")
.kernel_functions([KernelPack::Files])
.permission_profile(PermissionProfile::ReadOnly)
.model(ModelRef::of("lab", "lab-small")),
)
.unwrap();
assert_eq!(typed.kernel_functions, vec![KernelPack::Files]);
assert_eq!(typed.permission_profile, PermissionProfile::ReadOnly);
assert_eq!(
mb.bots().get_model(&typed.id).unwrap().effective,
"lab/lab-small"
);
mb.bots().delete(&typed.id).unwrap();
let catalog = mb.models().list().unwrap();
assert!(catalog.choices.iter().any(|c| c == "lab/lab-small"));
assert_eq!(
mb.models()
.set_default(&ModelRef::of("lab", "lab-small"))
.unwrap(),
"lab/lab-small"
);
let wren = mb.bots().get_model("wren").unwrap();
assert!(wren.uses_default);
assert_eq!(wren.effective, "lab/lab-small");
let atlas = mb.threads().create("atlas", None).unwrap();
let (tx, rx) = mpsc::channel();
let stream = mb.events().stream(Some(&atlas.id)).unwrap();
thread::spawn(move || {
for e in stream.flatten() {
if e.event_type == EventType::TaskStateChanged && e.data.as_deref() == Some("Completed")
{
let _ = tx.send(true);
return;
}
}
});
mb.threads().send(&atlas.id, "ping", false).unwrap();
assert!(
rx.recv_timeout(Duration::from_secs(30)).unwrap_or(false),
"event stream reports completion"
);
assert!(mb.approvals().set_skip_approvals(true).unwrap());
assert!(mb.approvals().skip_approvals().unwrap());
assert!(!mb.approvals().set_skip_approvals(false).unwrap());
assert!(mb.approvals().pending().unwrap().is_empty());
let job = mb
.schedules()
.create(&ScheduleSpec::cron("weekly", "atlas", "brief", "0 8 * * 1"))
.unwrap();
assert!(job.next_run_at.is_some());
mb.schedules().delete(&job.id).unwrap();
assert_eq!(
mb.schedules()
.create(&ScheduleSpec::cron("bad", "atlas", "x", "nope"))
.unwrap_err()
.status(),
Some(400)
);
assert!(mb
.templates()
.list("designer", "")
.unwrap()
.iter()
.any(|t| t.id == "ux-designer"));
let hosts = mb.agent_hosts().list().unwrap();
let local = hosts.iter().find(|h| h.id == HostRef::LOCAL).unwrap();
assert!(local.capabilities.iter().any(|c| c == "shell"));
let token = mb.agent_hosts().create_enrollment("lab-pc", 10).unwrap();
assert!(
token.token.starts_with("mbe_") && token.enroll_command.contains("marbots-host enroll")
);
assert_eq!(
mb.agent_hosts()
.disable("host-that-does-not-exist")
.unwrap_err()
.status(),
Some(404)
);
let placed = mb
.bots()
.create(
BotSpec::new("Placed Rust")
.host_ref(HostRef::AUTO)
.container(ContainerProfile {
image: "python:3.12-slim".into(),
cpus: 1.5,
memory_mb: 512,
network: false,
})
.kernel_functions([KernelPack::Shell, KernelPack::Subagents]),
)
.unwrap();
assert_eq!(placed.host_ref, HostRef::AUTO);
let container = placed.container.unwrap();
assert_eq!(
(container.cpus, container.memory_mb, container.network),
(1.5, 512, false)
);
let evals = mb.skills().evaluations().unwrap();
assert!(
!evals.is_empty()
&& evals
.iter()
.all(|e| e.verdict == SkillVerdict::CollectingEvidence)
);
assert!(mb.skills().set_auto_rollback(true).unwrap());
assert!(mb.skills().auto_rollback().unwrap());
assert!(!mb.skills().set_auto_rollback(false).unwrap());
assert_eq!(
mb.skills().rollback("no-such-skill").unwrap_err().status(),
Some(404)
);
}