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
13use anyhow::{anyhow, Result};
14use serde::{Deserialize, Serialize};
15
16// --- Protocol types (shared between client and server) ---
17
18/// A command sent from the test client to the running application.
19///
20/// Serialized with `#[serde(tag = "cmd")]`. See the crate-level docs for
21/// the full command reference.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23#[serde(tag = "cmd")]
24pub enum TestCommand {
25    Tap { x: f32, y: f32 },
26    TapText { text: String },
27    Scroll { x: f32, y: f32, dx: f32, dy: f32 },
28    TypeText { text: String },
29    PressKey { key: String, modifiers: u8 },
30    Screenshot { path: String },
31    GetText {},
32    GetTree {},
33    Wait { ms: u64 },
34    Pump {},
35    Quit {},
36}
37
38/// A visible text element with its bounding rectangle, returned by [`TestCommand::GetText`].
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct TextItem {
41    pub text: String,
42    pub x: f32,
43    pub y: f32,
44    pub width: f32,
45    pub height: f32,
46}
47
48/// A node in the semantic accessibility tree, returned by [`TestCommand::GetTree`].
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SemanticNode {
51    pub role: String,
52    pub label: Option<String>,
53    pub value: Option<String>,
54    pub focusable: bool,
55    pub x: f32,
56    pub y: f32,
57    pub width: f32,
58    pub height: f32,
59}
60
61/// The response from the application to a [`TestCommand`].
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "status")]
64pub enum TestResponse {
65    Ok {},
66    Text { items: Vec<TextItem> },
67    Tree { nodes: Vec<SemanticNode> },
68    Error { message: String },
69}
70
71// --- Client ---
72
73/// An HTTP client that drives a running Fission application for automated UI testing.
74///
75/// Connect to a running application via [`LiveTestClient::connect(port)`]. The
76/// application must have been started with `FISSION_TEST_CONTROL_PORT=<port>`.
77///
78/// # Example
79///
80/// ```rust,ignore
81/// let client = LiveTestClient::connect(9876);
82/// client.wait_for_ready(5000).unwrap();
83/// client.tap_text("Submit").unwrap();
84/// client.assert_text_visible("Success").unwrap();
85/// client.screenshot("/tmp/result.png").unwrap();
86/// client.quit().unwrap();
87/// ```
88pub struct LiveTestClient {
89    base_url: String,
90}
91
92impl LiveTestClient {
93    pub fn connect(port: u16) -> Self {
94        Self {
95            base_url: format!("http://127.0.0.1:{}", port),
96        }
97    }
98
99    pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
100        let start = std::time::Instant::now();
101        let timeout = std::time::Duration::from_millis(timeout_ms);
102        loop {
103            match ureq::get(&format!("{}/health", self.base_url)).call() {
104                Ok(_) => return Ok(()),
105                Err(_) => {
106                    if start.elapsed() > timeout {
107                        return Err(anyhow!("timed out waiting for test server"));
108                    }
109                    std::thread::sleep(std::time::Duration::from_millis(100));
110                }
111            }
112        }
113    }
114
115    fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
116        let body = serde_json::to_string(&cmd)?;
117        let resp = ureq::post(&format!("{}/cmd", self.base_url))
118            .set("Content-Type", "application/json")
119            .send_string(&body)
120            .map_err(|e| anyhow!("request failed: {}", e))?;
121        let text = resp.into_string()?;
122        let response: TestResponse = serde_json::from_str(&text)?;
123        if let TestResponse::Error { message } = &response {
124            return Err(anyhow!("server error: {}", message));
125        }
126        Ok(response)
127    }
128
129    pub fn tap(&self, x: f32, y: f32) -> Result<()> {
130        self.send(TestCommand::Tap { x, y })?;
131        Ok(())
132    }
133
134    pub fn tap_text(&self, text: &str) -> Result<()> {
135        // Pump first to ensure layout positions are current
136        self.pump()?;
137        self.send(TestCommand::TapText {
138            text: text.to_string(),
139        })?;
140        // Pump after to render the result of the tap
141        self.pump()?;
142        Ok(())
143    }
144
145    pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
146        self.send(TestCommand::Scroll { x, y, dx, dy })?;
147        Ok(())
148    }
149
150    pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
151        self.send(TestCommand::PressKey {
152            key: key.to_string(),
153            modifiers,
154        })?;
155        self.pump()?;
156        Ok(())
157    }
158
159    pub fn type_text(&self, text: &str) -> Result<()> {
160        self.send(TestCommand::TypeText {
161            text: text.to_string(),
162        })?;
163        Ok(())
164    }
165
166    pub fn screenshot(&self, path: &str) -> Result<()> {
167        self.send(TestCommand::Screenshot {
168            path: path.to_string(),
169        })?;
170        Ok(())
171    }
172
173    pub fn get_text(&self) -> Result<Vec<TextItem>> {
174        match self.send(TestCommand::GetText {})? {
175            TestResponse::Text { items } => Ok(items),
176            other => Err(anyhow!("unexpected response: {:?}", other)),
177        }
178    }
179
180    pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
181        match self.send(TestCommand::GetTree {})? {
182            TestResponse::Tree { nodes } => Ok(nodes),
183            other => Err(anyhow!("unexpected response: {:?}", other)),
184        }
185    }
186
187    pub fn wait(&self, ms: u64) -> Result<()> {
188        self.send(TestCommand::Wait { ms })?;
189        Ok(())
190    }
191
192    pub fn pump(&self) -> Result<()> {
193        self.send(TestCommand::Pump {})?;
194        Ok(())
195    }
196
197    pub fn quit(&self) -> Result<()> {
198        let _ = self.send(TestCommand::Quit {});
199        Ok(())
200    }
201
202    // --- High-level helpers ---
203
204    pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
205        self.tap_text(text)?;
206        self.wait(ms)?;
207        Ok(())
208    }
209
210    pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
211        let items = self.get_text()?;
212        let found = items.iter().any(|t| t.text.contains(needle));
213        if !found {
214            let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
215            return Err(anyhow!(
216                "expected '{}' to be visible, found: {:?}",
217                needle,
218                &all[..all.len().min(20)]
219            ));
220        }
221        Ok(())
222    }
223
224    pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
225        let items = self.get_text()?;
226        let found = items.iter().any(|t| t.text.contains(needle));
227        if found {
228            return Err(anyhow!("expected '{}' to NOT be visible", needle));
229        }
230        Ok(())
231    }
232}