Skip to main content

fission_test_driver/
lib.rs

1//! Automated UI testing client and protocol for Fission applications.
2//!
3//! This crate provides the JSON protocol types (shared between the test client
4//! and the desktop shell server) and a [`LiveTestClient`] that drives a running
5//! Fission application over HTTP.
6//!
7//! # Architecture
8//!
9//! The application must be launched with `FISSION_TEST_CONTROL_PORT=<port>`.
10//! The [`LiveTestClient`] connects to `http://127.0.0.1:<port>` and sends
11//! [`TestCommand`] JSON payloads to `/cmd`, receiving [`TestResponse`] replies.
12
13#[cfg(not(target_arch = "wasm32"))]
14use anyhow::{anyhow, Result};
15#[cfg(not(target_arch = "wasm32"))]
16use base64::Engine;
17use serde::{Deserialize, Serialize};
18
19// --- Protocol types (shared between client and server) ---
20
21/// A command sent from the test client to the running application.
22///
23/// Serialized with `#[serde(tag = "cmd")]`. See the crate-level docs for
24/// the full command reference.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(tag = "cmd")]
27pub enum TestCommand {
28    Tap {
29        x: f32,
30        y: f32,
31    },
32    Drag {
33        start_x: f32,
34        start_y: f32,
35        end_x: f32,
36        end_y: f32,
37        steps: u32,
38    },
39    TapText {
40        text: String,
41    },
42    Scroll {
43        x: f32,
44        y: f32,
45        dx: f32,
46        dy: f32,
47    },
48    TypeText {
49        text: String,
50    },
51    PressKey {
52        key: String,
53        modifiers: u8,
54    },
55    Screenshot {
56        path: String,
57    },
58    CaptureScreenshot {},
59    GetText {},
60    GetTree {},
61    Wait {
62        ms: u64,
63    },
64    Pump {},
65    Quit {},
66    // NEW: simulate real winit-level events for realistic testing
67    SimulateMouseMove {
68        x: f32,
69        y: f32,
70    },
71    SimulateRightClick {
72        x: f32,
73        y: f32,
74    },
75    SimulateResize {
76        /// Target logical viewport width in test-space pixels.
77        width: u32,
78        /// Target logical viewport height in test-space pixels.
79        height: u32,
80    },
81}
82
83/// Events injected into the winit event loop via `EventLoopProxy`.
84///
85/// Input-simulation variants (`MouseMove`, `MouseDown`, etc.) travel through
86/// the **same** `Event::UserEvent` → handler path as real `WindowEvent`s, so
87/// test code exercises identical code paths as real user interaction.
88///
89/// Query / control variants (`Screenshot`, `GetText`, etc.) also go through
90/// the proxy so the main loop can respond via a dedicated response channel.
91#[derive(Debug, Clone)]
92pub enum TestEvent {
93    // --- Input simulation (mirrors winit WindowEvents) ---
94    MouseMove {
95        x: f32,
96        y: f32,
97    },
98    MouseDown {
99        x: f32,
100        y: f32,
101        button: u8,
102    }, // 0=left, 1=right, 2=middle
103    MouseUp {
104        x: f32,
105        y: f32,
106        button: u8,
107    },
108    KeyDown {
109        key_code: String,
110        modifiers: u8,
111    },
112    KeyUp {
113        key_code: String,
114        modifiers: u8,
115    },
116    TextInput {
117        text: String,
118    },
119    Scroll {
120        x: f32,
121        y: f32,
122        dx: f32,
123        dy: f32,
124    },
125    Resize {
126        width: u32,
127        height: u32,
128    },
129    // --- Queries / control (need response channel) ---
130    Screenshot {
131        path: String,
132        response_tx: TestResponseSender,
133    },
134    CaptureScreenshot {
135        response_tx: TestResponseSender,
136    },
137    GetText {
138        response_tx: TestResponseSender,
139    },
140    GetTree {
141        response_tx: TestResponseSender,
142    },
143    Pump {
144        response_tx: TestResponseSender,
145    },
146    Wake,
147    Quit,
148    /// Internal: TapText resolves a text label to coordinates; the server
149    /// injects this so the main loop can do the lookup with access to the IR.
150    TapText {
151        text: String,
152        response_tx: TestResponseSender,
153    },
154    /// Internal: Wait is handled server-side (sleep) then responds.
155    Wait {
156        ms: u64,
157        response_tx: TestResponseSender,
158    },
159}
160
161/// A visible text element with its bounding rectangle, in logical test-space
162/// pixels, returned by [`TestCommand::GetText`].
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct TextItem {
165    pub text: String,
166    pub x: f32,
167    pub y: f32,
168    pub width: f32,
169    pub height: f32,
170}
171
172/// A node in the semantic accessibility tree, returned by [`TestCommand::GetTree`].
173/// Bounding rectangles are expressed in logical test-space pixels.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SemanticNode {
176    pub role: String,
177    pub label: Option<String>,
178    pub value: Option<String>,
179    pub focusable: bool,
180    pub x: f32,
181    pub y: f32,
182    pub width: f32,
183    pub height: f32,
184}
185
186/// The response from the application to a [`TestCommand`].
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(tag = "status")]
189pub enum TestResponse {
190    Ok {},
191    Text {
192        items: Vec<TextItem>,
193    },
194    Tree {
195        nodes: Vec<SemanticNode>,
196    },
197    Screenshot {
198        png_base64: String,
199        /// PNG width in logical test-space pixels.
200        width: u32,
201        /// PNG height in logical test-space pixels.
202        height: u32,
203    },
204    Error {
205        message: String,
206    },
207}
208
209/// Per-command response channel used by the shell event loop.
210pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
211
212// --- Client ---
213
214/// An HTTP client that drives a running Fission application for automated UI testing.
215///
216/// Connect to a running application via [`LiveTestClient::connect(port)`]. The
217/// application must have been started with `FISSION_TEST_CONTROL_PORT=<port>`.
218///
219/// # Example
220///
221/// ```rust,ignore
222/// let client = LiveTestClient::connect(9876);
223/// client.wait_for_ready(5000).unwrap();
224/// client.tap_text("Submit").unwrap();
225/// client.assert_text_visible("Success").unwrap();
226/// client.screenshot("/tmp/result.png").unwrap();
227/// client.quit().unwrap();
228/// ```
229#[cfg(not(target_arch = "wasm32"))]
230pub struct LiveTestClient {
231    base_url: String,
232}
233
234#[cfg(not(target_arch = "wasm32"))]
235impl LiveTestClient {
236    pub fn connect(port: u16) -> Self {
237        Self {
238            base_url: format!("http://127.0.0.1:{}", port),
239        }
240    }
241
242    pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
243        let start = std::time::Instant::now();
244        let timeout = std::time::Duration::from_millis(timeout_ms);
245        loop {
246            match ureq::get(&format!("{}/health", self.base_url)).call() {
247                Ok(_) => return Ok(()),
248                Err(_) => {
249                    if start.elapsed() > timeout {
250                        return Err(anyhow!("timed out waiting for test server"));
251                    }
252                    std::thread::sleep(std::time::Duration::from_millis(100));
253                }
254            }
255        }
256    }
257
258    fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
259        let body = serde_json::to_string(&cmd)?;
260        let resp = ureq::post(&format!("{}/cmd", self.base_url))
261            .set("Content-Type", "application/json")
262            .send_string(&body)
263            .map_err(|e| anyhow!("request failed: {}", e))?;
264        let text = resp.into_string()?;
265        let response: TestResponse = serde_json::from_str(&text)?;
266        if let TestResponse::Error { message } = &response {
267            return Err(anyhow!("server error: {}", message));
268        }
269        Ok(response)
270    }
271
272    pub fn tap(&self, x: f32, y: f32) -> Result<()> {
273        self.send(TestCommand::Tap { x, y })?;
274        Ok(())
275    }
276
277    pub fn tap_text(&self, text: &str) -> Result<()> {
278        // Pump first to ensure layout positions are current
279        self.pump()?;
280        self.send(TestCommand::TapText {
281            text: text.to_string(),
282        })?;
283        // Pump after to render the result of the tap
284        self.pump()?;
285        Ok(())
286    }
287
288    pub fn tap_text_without_pump(&self, text: &str) -> Result<()> {
289        self.send(TestCommand::TapText {
290            text: text.to_string(),
291        })?;
292        Ok(())
293    }
294
295    pub fn drag(
296        &self,
297        start_x: f32,
298        start_y: f32,
299        end_x: f32,
300        end_y: f32,
301        steps: u32,
302    ) -> Result<()> {
303        self.send(TestCommand::Drag {
304            start_x,
305            start_y,
306            end_x,
307            end_y,
308            steps,
309        })?;
310        self.pump()?;
311        Ok(())
312    }
313
314    pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
315        self.send(TestCommand::Scroll { x, y, dx, dy })?;
316        Ok(())
317    }
318
319    pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
320        self.send(TestCommand::PressKey {
321            key: key.to_string(),
322            modifiers,
323        })?;
324        self.pump()?;
325        Ok(())
326    }
327
328    pub fn type_text(&self, text: &str) -> Result<()> {
329        self.send(TestCommand::TypeText {
330            text: text.to_string(),
331        })?;
332        Ok(())
333    }
334
335    pub fn screenshot(&self, path: &str) -> Result<()> {
336        match self.send(TestCommand::CaptureScreenshot {})? {
337            TestResponse::Screenshot {
338                png_base64,
339                width: _,
340                height: _,
341            } => {
342                let bytes = base64::engine::general_purpose::STANDARD
343                    .decode(png_base64)
344                    .map_err(|e| anyhow!("invalid screenshot payload: {}", e))?;
345                std::fs::write(path, bytes)?;
346                Ok(())
347            }
348            other => Err(anyhow!(
349                "unexpected response to CaptureScreenshot: {:?}",
350                other
351            )),
352        }
353    }
354
355    pub fn get_text(&self) -> Result<Vec<TextItem>> {
356        match self.send(TestCommand::GetText {})? {
357            TestResponse::Text { items } => Ok(items),
358            other => Err(anyhow!("unexpected response: {:?}", other)),
359        }
360    }
361
362    pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
363        match self.send(TestCommand::GetTree {})? {
364            TestResponse::Tree { nodes } => Ok(nodes),
365            other => Err(anyhow!("unexpected response: {:?}", other)),
366        }
367    }
368
369    pub fn wait(&self, ms: u64) -> Result<()> {
370        self.send(TestCommand::Wait { ms })?;
371        Ok(())
372    }
373
374    pub fn pump(&self) -> Result<()> {
375        self.send(TestCommand::Pump {})?;
376        Ok(())
377    }
378
379    pub fn quit(&self) -> Result<()> {
380        let _ = self.send(TestCommand::Quit {});
381        Ok(())
382    }
383
384    // --- NEW: simulate real winit-level events ---
385
386    /// Simulate a mouse move to (x, y) — goes through the real CursorMoved path.
387    pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
388        self.send(TestCommand::SimulateMouseMove { x, y })?;
389        Ok(())
390    }
391
392    /// Simulate a right-click at (x, y) — move + down + up with right button.
393    pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
394        self.send(TestCommand::SimulateRightClick { x, y })?;
395        Ok(())
396    }
397
398    /// Simulate a window resize in logical test-space pixels.
399    pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
400        self.send(TestCommand::SimulateResize { width, height })?;
401        Ok(())
402    }
403
404    // --- High-level helpers ---
405
406    pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
407        self.tap_text(text)?;
408        self.wait(ms)?;
409        Ok(())
410    }
411
412    pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
413        let items = self.get_text()?;
414        let found = items.iter().any(|t| t.text.contains(needle));
415        if !found {
416            let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
417            return Err(anyhow!(
418                "expected '{}' to be visible, found: {:?}",
419                needle,
420                &all[..all.len().min(20)]
421            ));
422        }
423        Ok(())
424    }
425
426    pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
427        let items = self.get_text()?;
428        let found = items.iter().any(|t| t.text.contains(needle));
429        if found {
430            return Err(anyhow!("expected '{}' to NOT be visible", needle));
431        }
432        Ok(())
433    }
434}