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 = crate::discovery::configured_port().unwrap_or(0);
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, token) = self.discover_actual_connection();
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 = 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_connection(&self) -> (u16, Option<String>) {
273 if let Some(child) = &self.child {
276 return crate::discovery::scan_discovery_dir_for_pid(child.id()).unwrap_or((0, None));
277 }
278 if self.port != 0 {
279 let token = std::env::var("VICTAURI_AUTH_TOKEN")
280 .ok()
281 .filter(|value| !value.trim().is_empty())
282 .or_else(|| crate::discovery::scan_discovery_dirs_for_token_on_port(self.port));
283 return (self.port, token);
284 }
285 crate::discovery::resolve_connection()
286 }
287}
288
289impl Drop for TestApp {
290 fn drop(&mut self) {
291 if let Some(mut child) = self.child.take() {
292 let _ = child.kill();
293 let _ = child.wait();
294 }
295 }
296}
297
298fn spawn_stderr_reader(
303 stderr: Option<std::process::ChildStderr>,
304) -> (Arc<Mutex<Vec<String>>>, Option<std::thread::JoinHandle<()>>) {
305 let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
306
307 let handle = stderr.map(|pipe| {
308 let lines = Arc::clone(&lines);
309 std::thread::Builder::new()
310 .name("victauri-stderr-reader".into())
311 .spawn(move || {
312 let reader = std::io::BufReader::new(pipe);
313 for line in reader.lines() {
314 match line {
315 Ok(text) => {
316 let mut buf = lines
317 .lock()
318 .unwrap_or_else(std::sync::PoisonError::into_inner);
319 if buf.len() >= STDERR_MAX_LINES {
320 buf.remove(0);
321 }
322 buf.push(text);
323 }
324 Err(_) => break,
325 }
326 }
327 })
328 .expect("failed to spawn stderr reader thread")
329 });
330
331 (lines, handle)
332}