use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::Duration;
use crate::VictauriClient;
use crate::error::TestError;
pub struct TestApp {
child: Option<Child>,
port: u16,
token: Option<String>,
}
impl TestApp {
pub async fn spawn(cmd: &str) -> Result<Self, TestError> {
Self::spawn_with_options(cmd, None, Duration::from_secs(30)).await
}
pub async fn spawn_with_options(
cmd: &str,
port: Option<u16>,
timeout: Duration,
) -> Result<Self, TestError> {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if parts.is_empty() {
return Err(TestError::Connection("empty command".into()));
}
let child = Command::new(parts[0])
.args(&parts[1..])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| TestError::Connection(format!("failed to spawn `{cmd}`: {e}")))?;
let mut app = Self {
child: Some(child),
port: port.unwrap_or(0),
token: None,
};
app.wait_for_ready(timeout).await?;
Ok(app)
}
pub async fn spawn_demo() -> Result<Self, TestError> {
let port = discover_port();
let parts = ["cargo", "run", "-p", "demo-app"];
let child = Command::new(parts[0])
.args(&parts[1..])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| TestError::Connection(format!("failed to spawn demo-app: {e}")))?;
let mut app = Self {
child: Some(child),
port,
token: None,
};
app.wait_for_ready(Duration::from_secs(60)).await?;
Ok(app)
}
pub async fn attach(port: u16, token: Option<String>) -> Result<Self, TestError> {
let app = Self {
child: None,
port,
token,
};
let http = reqwest::Client::new();
let url = format!("http://127.0.0.1:{port}/health");
let resp = http
.get(&url)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| TestError::Connection(format!("health check failed: {e}")))?;
if !resp.status().is_success() {
return Err(TestError::Connection(format!(
"health returned {}",
resp.status()
)));
}
Ok(app)
}
pub async fn client(&self) -> Result<VictauriClient, TestError> {
VictauriClient::connect_with_token(self.port, self.token.as_deref()).await
}
#[must_use]
pub fn port(&self) -> u16 {
self.port
}
async fn wait_for_ready(&mut self, timeout: Duration) -> Result<(), TestError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
.map_err(|e| TestError::Connection(e.to_string()))?;
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(200);
loop {
if start.elapsed() > timeout {
return Err(TestError::Connection(format!(
"app did not become ready within {}s — check that the Victauri plugin is \
initialized and the MCP server is listening. Try setting VICTAURI_PORT or \
checking the app's stderr for errors.",
timeout.as_secs()
)));
}
if let Some(ref mut child) = self.child
&& let Some(status) = child.try_wait().ok().flatten()
{
return Err(TestError::Connection(format!(
"app process exited with {status} before becoming ready"
)));
}
let port = self.discover_actual_port();
let url = format!("http://127.0.0.1:{port}/health");
if let Ok(resp) = http.get(&url).send().await
&& resp.status().is_success()
{
self.port = port;
self.token = discover_token();
return Ok(());
}
tokio::time::sleep(poll_interval).await;
}
}
fn discover_actual_port(&self) -> u16 {
if self.port != 0 {
return self.port;
}
discover_port()
}
}
impl Drop for TestApp {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
fn discover_port() -> u16 {
if let Ok(p) = std::env::var("VICTAURI_PORT")
&& let Ok(port) = p.parse::<u16>()
{
return port;
}
let path = port_file_path();
if let Ok(contents) = std::fs::read_to_string(&path)
&& let Ok(port) = contents.trim().parse::<u16>()
{
return port;
}
7373
}
fn discover_token() -> Option<String> {
if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
return Some(token);
}
let path = token_file_path();
let token = std::fs::read_to_string(&path).ok()?;
let token = token.trim().to_string();
if token.is_empty() { None } else { Some(token) }
}
fn port_file_path() -> PathBuf {
std::env::temp_dir().join("victauri.port")
}
fn token_file_path() -> PathBuf {
std::env::temp_dir().join("victauri.token")
}