Skip to main content

victauri_test/
app.rs

1use std::path::PathBuf;
2use std::process::{Child, Command, Stdio};
3use std::time::Duration;
4
5use crate::VictauriClient;
6use crate::error::TestError;
7
8/// Managed Tauri application lifecycle for integration testing.
9///
10/// Spawns a Tauri app as a child process, waits for the Victauri MCP server
11/// to become healthy, and provides connected [`VictauriClient`] instances.
12/// The app is killed when the `TestApp` is dropped.
13///
14/// # Example
15///
16/// ```rust,ignore
17/// use victauri_test::TestApp;
18///
19/// #[tokio::test]
20/// async fn my_app_works() {
21///     let app = TestApp::spawn("cargo run -p my-app").await.unwrap();
22///     let mut client = app.client().await.unwrap();
23///     client.click_by_text("Submit").await.unwrap();
24///     client.expect_text("Success").await.unwrap();
25/// }
26/// ```
27pub struct TestApp {
28    child: Option<Child>,
29    port: u16,
30    token: Option<String>,
31}
32
33impl TestApp {
34    /// Spawn an application from a shell command and wait for it to become ready.
35    ///
36    /// Polls the Victauri health endpoint until it responds (up to 30 seconds).
37    /// Uses port auto-discovery via temp files, falling back to env vars and defaults.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`TestError::Connection`] if the app fails to start or the health
42    /// endpoint doesn't respond within the timeout.
43    pub async fn spawn(cmd: &str) -> Result<Self, TestError> {
44        Self::spawn_with_options(cmd, None, Duration::from_secs(30)).await
45    }
46
47    /// Spawn with explicit port and timeout configuration.
48    ///
49    /// # Errors
50    ///
51    /// Returns [`TestError::Connection`] if the app fails to start or the health
52    /// endpoint doesn't respond within the timeout.
53    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    /// Spawn the bundled demo app from the workspace.
81    ///
82    /// Equivalent to `TestApp::spawn("cargo run -p demo-app")` but with
83    /// appropriate environment variables set.
84    ///
85    /// # Errors
86    ///
87    /// Returns [`TestError::Connection`] if the demo app fails to start.
88    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    /// Connect to an already-running Victauri app (no process management).
110    ///
111    /// Useful when the app is started externally (e.g., by CI or a dev script).
112    ///
113    /// # Errors
114    ///
115    /// Returns [`TestError::Connection`] if the health endpoint doesn't respond.
116    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    /// Create a new connected [`VictauriClient`] for this app.
143    ///
144    /// Each call returns a fresh MCP session.
145    ///
146    /// # Errors
147    ///
148    /// Returns errors from [`VictauriClient::connect_with_token`].
149    pub async fn client(&self) -> Result<VictauriClient, TestError> {
150        VictauriClient::connect_with_token(self.port, self.token.as_deref()).await
151    }
152
153    /// The port the MCP server is running on.
154    #[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}