1use anyhow::{anyhow, Result};
14use serde::{Deserialize, Serialize};
15
16#[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#[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#[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#[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
71pub 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 self.pump()?;
137 self.send(TestCommand::TapText {
138 text: text.to_string(),
139 })?;
140 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 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}