1#[cfg(not(target_arch = "wasm32"))]
14use anyhow::{anyhow, Result};
15#[cfg(not(target_arch = "wasm32"))]
16use base64::Engine;
17use serde::{Deserialize, Serialize};
18
19#[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 SimulateMouseMove {
68 x: f32,
69 y: f32,
70 },
71 SimulateRightClick {
72 x: f32,
73 y: f32,
74 },
75 SimulateResize {
76 width: u32,
78 height: u32,
80 },
81}
82
83#[derive(Debug, Clone)]
92pub enum TestEvent {
93 MouseMove {
95 x: f32,
96 y: f32,
97 },
98 MouseDown {
99 x: f32,
100 y: f32,
101 button: u8,
102 }, 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 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 TapText {
151 text: String,
152 response_tx: TestResponseSender,
153 },
154 Wait {
156 ms: u64,
157 response_tx: TestResponseSender,
158 },
159}
160
161#[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#[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#[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 width: u32,
201 height: u32,
203 },
204 Error {
205 message: String,
206 },
207}
208
209pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
211
212#[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 self.pump()?;
280 self.send(TestCommand::TapText {
281 text: text.to_string(),
282 })?;
283 self.pump()?;
285 Ok(())
286 }
287
288 pub fn drag(
289 &self,
290 start_x: f32,
291 start_y: f32,
292 end_x: f32,
293 end_y: f32,
294 steps: u32,
295 ) -> Result<()> {
296 self.send(TestCommand::Drag {
297 start_x,
298 start_y,
299 end_x,
300 end_y,
301 steps,
302 })?;
303 self.pump()?;
304 Ok(())
305 }
306
307 pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
308 self.send(TestCommand::Scroll { x, y, dx, dy })?;
309 Ok(())
310 }
311
312 pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
313 self.send(TestCommand::PressKey {
314 key: key.to_string(),
315 modifiers,
316 })?;
317 self.pump()?;
318 Ok(())
319 }
320
321 pub fn type_text(&self, text: &str) -> Result<()> {
322 self.send(TestCommand::TypeText {
323 text: text.to_string(),
324 })?;
325 Ok(())
326 }
327
328 pub fn screenshot(&self, path: &str) -> Result<()> {
329 match self.send(TestCommand::CaptureScreenshot {})? {
330 TestResponse::Screenshot {
331 png_base64,
332 width: _,
333 height: _,
334 } => {
335 let bytes = base64::engine::general_purpose::STANDARD
336 .decode(png_base64)
337 .map_err(|e| anyhow!("invalid screenshot payload: {}", e))?;
338 std::fs::write(path, bytes)?;
339 Ok(())
340 }
341 other => Err(anyhow!(
342 "unexpected response to CaptureScreenshot: {:?}",
343 other
344 )),
345 }
346 }
347
348 pub fn get_text(&self) -> Result<Vec<TextItem>> {
349 match self.send(TestCommand::GetText {})? {
350 TestResponse::Text { items } => Ok(items),
351 other => Err(anyhow!("unexpected response: {:?}", other)),
352 }
353 }
354
355 pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
356 match self.send(TestCommand::GetTree {})? {
357 TestResponse::Tree { nodes } => Ok(nodes),
358 other => Err(anyhow!("unexpected response: {:?}", other)),
359 }
360 }
361
362 pub fn wait(&self, ms: u64) -> Result<()> {
363 self.send(TestCommand::Wait { ms })?;
364 Ok(())
365 }
366
367 pub fn pump(&self) -> Result<()> {
368 self.send(TestCommand::Pump {})?;
369 Ok(())
370 }
371
372 pub fn quit(&self) -> Result<()> {
373 let _ = self.send(TestCommand::Quit {});
374 Ok(())
375 }
376
377 pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
381 self.send(TestCommand::SimulateMouseMove { x, y })?;
382 Ok(())
383 }
384
385 pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
387 self.send(TestCommand::SimulateRightClick { x, y })?;
388 Ok(())
389 }
390
391 pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
393 self.send(TestCommand::SimulateResize { width, height })?;
394 Ok(())
395 }
396
397 pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
400 self.tap_text(text)?;
401 self.wait(ms)?;
402 Ok(())
403 }
404
405 pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
406 let items = self.get_text()?;
407 let found = items.iter().any(|t| t.text.contains(needle));
408 if !found {
409 let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
410 return Err(anyhow!(
411 "expected '{}' to be visible, found: {:?}",
412 needle,
413 &all[..all.len().min(20)]
414 ));
415 }
416 Ok(())
417 }
418
419 pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
420 let items = self.get_text()?;
421 let found = items.iter().any(|t| t.text.contains(needle));
422 if found {
423 return Err(anyhow!("expected '{}' to NOT be visible", needle));
424 }
425 Ok(())
426 }
427}