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