//! Helpers for end-to-end tests: a hermetic environment, the owner's //! delegation as Spectra makes it, agents added through the CLI, and the //! daemon started and stopped as a real process. #![allow(dead_code)] use spectra_core::keys::KeyPair; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Output}; pub fn hermetic(dir: &Path) -> Command { let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); c.env("SPECTRA_DATA", dir.join("data")) .env("SPECTRA_HOME", dir.join("owner")) .env("HOME", dir) .env("XDG_DATA_HOME", dir.join("xdg")) .env("NO_COLOR", "1"); c } pub fn run(dir: &Path, args: &[&str]) -> Output { hermetic(dir).args(args).output().unwrap() } pub fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// The owner delegates to `key`, as `spectra agent add` and `connect` do. pub fn delegate(owner_home: &Path, agent: &KeyPair) { spectra_core::keys::delegate(owner_home, agent.key, None).unwrap(); } /// Add an agent through the CLI, confirming the delegation. pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair { let home = d.join("agents").join(name); let mut args = vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--yes"]; args.extend_from_slice(extra); let o = run(d, &args); assert!(o.status.success(), "{}", text(&o)); KeyPair::load_home(&home).unwrap() } pub struct Serving { pub child: Child, pub socket: PathBuf, } impl Serving { pub fn start(d: &Path) -> Serving { let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); let socket = d.join("data/spectra.sock"); for _ in 0..200 { if std::os::unix::net::UnixStream::connect(&socket).is_ok() { return Serving { child, socket }; } std::thread::sleep(std::time::Duration::from_millis(25)); } child.kill().ok(); child.wait().ok(); panic!("spectra serve did not start"); } /// Serve with TLS on a loopback port of its own; returns the port. pub fn start_tls(d: &Path) -> (Serving, u16) { use std::io::BufRead; let mut child = hermetic(d).args(["serve", "--bind", "127.0.0.1:0", "--tls", "on"]).stdout(std::process::Stdio::piped()).spawn().unwrap(); let out = child.stdout.take().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { for line in std::io::BufReader::new(out).lines().map_while(Result::ok) { if let Some(url) = line.split_whitespace().find(|w| w.starts_with("https://")) { let port: u16 = url.rsplit(':').next().unwrap().parse().unwrap(); let _ = tx.send(port); } } }); let port = rx.recv_timeout(std::time::Duration::from_secs(10)).expect("spectra serve printed its https address"); (Serving { child, socket: d.join("data/spectra.sock") }, port) } pub fn stop(mut self) { unsafe { libc::kill(self.child.id() as i32, libc::SIGTERM) }; let status = self.child.wait().unwrap(); assert!(status.success(), "a clean stop on SIGTERM"); assert!(!self.socket.exists(), "the socket is removed"); } } /// A test that fails mid-way must not leave its daemon running: an orphan /// holds the test's output open and looks like a hang. impl Drop for Serving { fn drop(&mut self) { if let Ok(None) = self.child.try_wait() { let _ = self.child.kill(); let _ = self.child.wait(); } } } pub fn setup() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); let d = dir.path(); KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap(); assert!(run(d, &["init"]).status.success()); std::fs::create_dir_all(d.join("oxygen")).unwrap(); assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); dir }