Skip to main content

victauri_test/
app.rs

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
9/// Maximum number of stderr lines retained in the ring buffer.
10const STDERR_MAX_LINES: usize = 50;
11
12/// Number of stderr lines included in error messages.
13const STDERR_DISPLAY_LINES: usize = 10;
14
15/// Managed Tauri application lifecycle for integration testing.
16///
17/// Spawns a Tauri app as a child process, waits for the Victauri MCP server
18/// to become healthy, and provides connected [`VictauriClient`] instances.
19/// The app is killed when the `TestApp` is dropped.
20///
21/// Stderr output from the spawned process is captured in a background thread
22/// and the last few lines are included in error messages when the app fails
23/// to start or times out, making startup failures much easier to diagnose.
24///
25/// # Example
26///
27/// ```rust,ignore
28/// use victauri_test::TestApp;
29///
30/// #[tokio::test]
31/// async fn my_app_works() {
32///     let app = TestApp::spawn("cargo run -p my-app").await.unwrap();
33///     let mut client = app.client().await.unwrap();
34///     client.click_by_text("Submit").await.unwrap();
35///     client.expect_text("Success").await.unwrap();
36/// }
37/// ```
38pub 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    /// Spawn an application from a shell command and wait for it to become ready.
48    ///
49    /// Polls the Victauri health endpoint until it responds (up to 30 seconds).
50    /// Uses port auto-discovery via temp files, falling back to env vars and defaults.
51    ///
52    /// # Errors
53    ///
54    /// Returns [`TestError::Connection`] if the app fails to start or the health
55    /// endpoint doesn't respond within the timeout.
56    pub async fn spawn(cmd: &str) -> Result<Self, TestError> {
57        Self::spawn_with_options(cmd, None, Duration::from_secs(30)).await
58    }
59
60    /// Spawn with explicit port and timeout configuration.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`TestError::Connection`] if the app fails to start or the health
65    /// endpoint doesn't respond within the timeout.
66    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    /// Spawn the bundled demo app from the workspace.
106    ///
107    /// Equivalent to `TestApp::spawn("cargo run -p demo-app")` but with
108    /// appropriate environment variables set.
109    ///
110    /// # Errors
111    ///
112    /// Returns [`TestError::Connection`] if the demo app fails to start.
113    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    /// Connect to an already-running Victauri app (no process management).
143    ///
144    /// Useful when the app is started externally (e.g., by CI or a dev script).
145    ///
146    /// # Errors
147    ///
148    /// Returns [`TestError::Connection`] if the health endpoint doesn't respond.
149    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    /// Create a new connected [`VictauriClient`] for this app.
183    ///
184    /// Each call returns a fresh MCP session.
185    ///
186    /// # Errors
187    ///
188    /// Returns errors from [`VictauriClient::connect_with_token`].
189    pub async fn client(&self) -> Result<VictauriClient, TestError> {
190        VictauriClient::connect_with_token(self.port, self.token.as_deref()).await
191    }
192
193    /// The port the MCP server is running on.
194    #[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    /// Format the last N captured stderr lines for inclusion in error messages.
255    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
289/// Spawn a background thread that drains the child's stderr into a bounded ring buffer.
290///
291/// Returns the shared line buffer and an optional join handle. The thread exits
292/// naturally when the child's stderr pipe is closed (i.e., the process exits).
293fn 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}