1use std::path::PathBuf;
2use std::process::{Child, Command, Stdio};
3use std::time::Duration;
4
5use crate::VictauriClient;
6use crate::error::TestError;
7
8pub struct TestApp {
28 child: Option<Child>,
29 port: u16,
30 token: Option<String>,
31}
32
33impl TestApp {
34 pub async fn spawn(cmd: &str) -> Result<Self, TestError> {
44 Self::spawn_with_options(cmd, None, Duration::from_secs(30)).await
45 }
46
47 pub async fn spawn_with_options(
54 cmd: &str,
55 port: Option<u16>,
56 timeout: Duration,
57 ) -> Result<Self, TestError> {
58 let parts: Vec<&str> = cmd.split_whitespace().collect();
59 if parts.is_empty() {
60 return Err(TestError::Connection("empty command".into()));
61 }
62
63 let child = Command::new(parts[0])
64 .args(&parts[1..])
65 .stdout(Stdio::null())
66 .stderr(Stdio::null())
67 .spawn()
68 .map_err(|e| TestError::Connection(format!("failed to spawn `{cmd}`: {e}")))?;
69
70 let mut app = Self {
71 child: Some(child),
72 port: port.unwrap_or(0),
73 token: None,
74 };
75
76 app.wait_for_ready(timeout).await?;
77 Ok(app)
78 }
79
80 pub async fn spawn_demo() -> Result<Self, TestError> {
89 let port = discover_port();
90 let parts = ["cargo", "run", "-p", "demo-app"];
91
92 let child = Command::new(parts[0])
93 .args(&parts[1..])
94 .stdout(Stdio::null())
95 .stderr(Stdio::null())
96 .spawn()
97 .map_err(|e| TestError::Connection(format!("failed to spawn demo-app: {e}")))?;
98
99 let mut app = Self {
100 child: Some(child),
101 port,
102 token: None,
103 };
104
105 app.wait_for_ready(Duration::from_secs(60)).await?;
106 Ok(app)
107 }
108
109 pub async fn attach(port: u16, token: Option<String>) -> Result<Self, TestError> {
117 let app = Self {
118 child: None,
119 port,
120 token,
121 };
122
123 let http = reqwest::Client::new();
124 let url = format!("http://127.0.0.1:{port}/health");
125 let resp = http
126 .get(&url)
127 .timeout(Duration::from_secs(5))
128 .send()
129 .await
130 .map_err(|e| TestError::Connection(format!("health check failed: {e}")))?;
131
132 if !resp.status().is_success() {
133 return Err(TestError::Connection(format!(
134 "health returned {}",
135 resp.status()
136 )));
137 }
138
139 Ok(app)
140 }
141
142 pub async fn client(&self) -> Result<VictauriClient, TestError> {
150 VictauriClient::connect_with_token(self.port, self.token.as_deref()).await
151 }
152
153 #[must_use]
155 pub fn port(&self) -> u16 {
156 self.port
157 }
158
159 async fn wait_for_ready(&mut self, timeout: Duration) -> Result<(), TestError> {
160 let http = reqwest::Client::builder()
161 .timeout(Duration::from_secs(2))
162 .build()
163 .map_err(|e| TestError::Connection(e.to_string()))?;
164
165 let start = std::time::Instant::now();
166 let poll_interval = Duration::from_millis(200);
167
168 loop {
169 if start.elapsed() > timeout {
170 return Err(TestError::Connection(format!(
171 "app did not become ready within {}s — check that the Victauri plugin is \
172 initialized and the MCP server is listening. Try setting VICTAURI_PORT or \
173 checking the app's stderr for errors.",
174 timeout.as_secs()
175 )));
176 }
177
178 if let Some(ref mut child) = self.child
179 && let Some(status) = child.try_wait().ok().flatten()
180 {
181 return Err(TestError::Connection(format!(
182 "app process exited with {status} before becoming ready"
183 )));
184 }
185
186 let port = self.discover_actual_port();
187 let url = format!("http://127.0.0.1:{port}/health");
188
189 if let Ok(resp) = http.get(&url).send().await
190 && resp.status().is_success()
191 {
192 self.port = port;
193 self.token = discover_token();
194 return Ok(());
195 }
196
197 tokio::time::sleep(poll_interval).await;
198 }
199 }
200
201 fn discover_actual_port(&self) -> u16 {
202 if self.port != 0 {
203 return self.port;
204 }
205 discover_port()
206 }
207}
208
209impl Drop for TestApp {
210 fn drop(&mut self) {
211 if let Some(mut child) = self.child.take() {
212 let _ = child.kill();
213 let _ = child.wait();
214 }
215 }
216}
217
218fn discover_port() -> u16 {
219 if let Ok(p) = std::env::var("VICTAURI_PORT")
220 && let Ok(port) = p.parse::<u16>()
221 {
222 return port;
223 }
224 let path = port_file_path();
225 if let Ok(contents) = std::fs::read_to_string(&path)
226 && let Ok(port) = contents.trim().parse::<u16>()
227 {
228 return port;
229 }
230 7373
231}
232
233fn discover_token() -> Option<String> {
234 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
235 return Some(token);
236 }
237 let path = token_file_path();
238 let token = std::fs::read_to_string(&path).ok()?;
239 let token = token.trim().to_string();
240 if token.is_empty() { None } else { Some(token) }
241}
242
243fn port_file_path() -> PathBuf {
244 std::env::temp_dir().join("victauri.port")
245}
246
247fn token_file_path() -> PathBuf {
248 std::env::temp_dir().join("victauri.token")
249}