1use std::io::BufRead;
2use std::process::{Child, Command, Stdio};
3use std::sync::{Arc, Mutex};
4use std::time::Duration;
5
6use crate::VictauriClient;
7use crate::error::TestError;
8
9const STDERR_MAX_LINES: usize = 50;
11
12const STDERR_DISPLAY_LINES: usize = 10;
14
15pub struct TestApp {
39 child: Option<Child>,
40 port: u16,
41 token: Option<String>,
42 stderr_lines: Arc<Mutex<Vec<String>>>,
43 _stderr_thread: Option<std::thread::JoinHandle<()>>,
44}
45
46impl TestApp {
47 pub async fn spawn(cmd: &str) -> Result<Self, TestError> {
57 Self::spawn_with_options(cmd, None, Duration::from_secs(30)).await
58 }
59
60 pub async fn spawn_with_options(
67 cmd: &str,
68 port: Option<u16>,
69 timeout: Duration,
70 ) -> Result<Self, TestError> {
71 let parts: Vec<&str> = cmd.split_whitespace().collect();
72 if parts.is_empty() {
73 return Err(TestError::Connection {
74 host: "127.0.0.1".into(),
75 port: port.unwrap_or(0),
76 reason: "empty command".into(),
77 });
78 }
79
80 let mut child = Command::new(parts[0])
81 .args(&parts[1..])
82 .stdout(Stdio::null())
83 .stderr(Stdio::piped())
84 .spawn()
85 .map_err(|e| TestError::Connection {
86 host: "127.0.0.1".into(),
87 port: port.unwrap_or(0),
88 reason: format!("failed to spawn `{cmd}`: {e}"),
89 })?;
90
91 let (stderr_lines, stderr_thread) = spawn_stderr_reader(child.stderr.take());
92
93 let mut app = Self {
94 child: Some(child),
95 port: port.unwrap_or(0),
96 token: None,
97 stderr_lines,
98 _stderr_thread: stderr_thread,
99 };
100
101 app.wait_for_ready(timeout).await?;
102 Ok(app)
103 }
104
105 pub async fn spawn_demo() -> Result<Self, TestError> {
114 let port = discover_port();
115 let parts = ["cargo", "run", "-p", "demo-app"];
116
117 let mut child = Command::new(parts[0])
118 .args(&parts[1..])
119 .stdout(Stdio::null())
120 .stderr(Stdio::piped())
121 .spawn()
122 .map_err(|e| TestError::Connection {
123 host: "127.0.0.1".into(),
124 port,
125 reason: format!("failed to spawn demo-app: {e}"),
126 })?;
127
128 let (stderr_lines, stderr_thread) = spawn_stderr_reader(child.stderr.take());
129
130 let mut app = Self {
131 child: Some(child),
132 port,
133 token: None,
134 stderr_lines,
135 _stderr_thread: stderr_thread,
136 };
137
138 app.wait_for_ready(Duration::from_secs(60)).await?;
139 Ok(app)
140 }
141
142 pub async fn attach(port: u16, token: Option<String>) -> Result<Self, TestError> {
150 let app = Self {
151 child: None,
152 port,
153 token,
154 stderr_lines: Arc::new(Mutex::new(Vec::new())),
155 _stderr_thread: None,
156 };
157
158 let http = reqwest::Client::new();
159 let url = format!("http://127.0.0.1:{port}/health");
160 let resp = http
161 .get(&url)
162 .timeout(Duration::from_secs(5))
163 .send()
164 .await
165 .map_err(|e| TestError::Connection {
166 host: "127.0.0.1".into(),
167 port,
168 reason: format!("health check failed: {e}"),
169 })?;
170
171 if !resp.status().is_success() {
172 return Err(TestError::Connection {
173 host: "127.0.0.1".into(),
174 port,
175 reason: format!("health returned {}", resp.status()),
176 });
177 }
178
179 Ok(app)
180 }
181
182 pub async fn client(&self) -> Result<VictauriClient, TestError> {
190 VictauriClient::connect_with_token(self.port, self.token.as_deref()).await
191 }
192
193 #[must_use]
195 pub fn port(&self) -> u16 {
196 self.port
197 }
198
199 async fn wait_for_ready(&mut self, timeout: Duration) -> Result<(), TestError> {
200 let http = reqwest::Client::builder()
201 .timeout(Duration::from_secs(2))
202 .build()
203 .map_err(|e| TestError::Connection {
204 host: "127.0.0.1".into(),
205 port: self.port,
206 reason: e.to_string(),
207 })?;
208
209 let start = std::time::Instant::now();
210 let poll_interval = Duration::from_millis(200);
211
212 loop {
213 if start.elapsed() > timeout {
214 let stderr_tail = self.recent_stderr();
215 return Err(TestError::Connection {
216 host: "127.0.0.1".into(),
217 port: self.port,
218 reason: format!(
219 "app did not become ready within {}s — check that the Victauri plugin is \
220 initialized and the MCP server is listening.{stderr_tail}",
221 timeout.as_secs()
222 ),
223 });
224 }
225
226 if let Some(ref mut child) = self.child
227 && let Some(status) = child.try_wait().ok().flatten()
228 {
229 let stderr_tail = self.recent_stderr();
230 return Err(TestError::Connection {
231 host: "127.0.0.1".into(),
232 port: self.port,
233 reason: format!(
234 "app process exited with {status} before becoming ready{stderr_tail}"
235 ),
236 });
237 }
238
239 let port = self.discover_actual_port();
240 let url = format!("http://127.0.0.1:{port}/health");
241
242 if let Ok(resp) = http.get(&url).send().await
243 && resp.status().is_success()
244 {
245 self.port = port;
246 self.token = discover_token();
247 return Ok(());
248 }
249
250 tokio::time::sleep(poll_interval).await;
251 }
252 }
253
254 fn recent_stderr(&self) -> String {
256 let lines = self
257 .stderr_lines
258 .lock()
259 .unwrap_or_else(std::sync::PoisonError::into_inner);
260 if lines.is_empty() {
261 return String::new();
262 }
263 let start = lines.len().saturating_sub(STDERR_DISPLAY_LINES);
264 let tail: Vec<&str> = lines[start..].iter().map(String::as_str).collect();
265 format!(
266 "\n\nApp stderr (last {} lines):\n {}",
267 tail.len(),
268 tail.join("\n ")
269 )
270 }
271
272 fn discover_actual_port(&self) -> u16 {
273 if self.port != 0 {
274 return self.port;
275 }
276 discover_port()
277 }
278}
279
280impl Drop for TestApp {
281 fn drop(&mut self) {
282 if let Some(mut child) = self.child.take() {
283 let _ = child.kill();
284 let _ = child.wait();
285 }
286 }
287}
288
289fn spawn_stderr_reader(
294 stderr: Option<std::process::ChildStderr>,
295) -> (Arc<Mutex<Vec<String>>>, Option<std::thread::JoinHandle<()>>) {
296 let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
297
298 let handle = stderr.map(|pipe| {
299 let lines = Arc::clone(&lines);
300 std::thread::Builder::new()
301 .name("victauri-stderr-reader".into())
302 .spawn(move || {
303 let reader = std::io::BufReader::new(pipe);
304 for line in reader.lines() {
305 match line {
306 Ok(text) => {
307 let mut buf = lines
308 .lock()
309 .unwrap_or_else(std::sync::PoisonError::into_inner);
310 if buf.len() >= STDERR_MAX_LINES {
311 buf.remove(0);
312 }
313 buf.push(text);
314 }
315 Err(_) => break,
316 }
317 }
318 })
319 .expect("failed to spawn stderr reader thread")
320 });
321
322 (lines, handle)
323}
324
325fn discover_port() -> u16 {
326 if let Ok(p) = std::env::var("VICTAURI_PORT")
327 && let Ok(port) = p.parse::<u16>()
328 {
329 return port;
330 }
331 if let Some(port) = crate::discovery::scan_discovery_dirs_for_port() {
332 return port;
333 }
334 7373
335}
336
337fn discover_token() -> Option<String> {
338 if let Ok(token) = std::env::var("VICTAURI_AUTH_TOKEN") {
339 return Some(token);
340 }
341 if let Some(token) = crate::discovery::scan_discovery_dirs_for_token() {
342 return Some(token);
343 }
344 None
345}