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 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 SimulateMouseMove {
77 x: f32,
78 y: f32,
79 },
80 SimulateRightClick {
81 x: f32,
82 y: f32,
83 },
84 SimulateResize {
85 width: u32,
87 height: u32,
89 },
90}
91
92#[derive(Debug, Clone)]
101pub enum TestEvent {
102 MouseMove {
104 x: f32,
105 y: f32,
106 },
107 MouseDown {
108 x: f32,
109 y: f32,
110 button: u8,
111 }, 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 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 TapText {
168 text: String,
169 response_tx: TestResponseSender,
170 },
171 Wait {
173 ms: u64,
174 response_tx: TestResponseSender,
175 },
176}
177
178#[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#[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#[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 width: u32,
218 height: u32,
220 },
221 Error {
222 message: String,
223 },
224}
225
226pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
228
229#[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 self.pump()?;
297 self.send(TestCommand::TapText {
298 text: text.to_string(),
299 })?;
300 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 pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
429 self.send(TestCommand::SimulateMouseMove { x, y })?;
430 Ok(())
431 }
432
433 pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
435 self.send(TestCommand::SimulateRightClick { x, y })?;
436 Ok(())
437 }
438
439 pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
441 self.send(TestCommand::SimulateResize { width, height })?;
442 Ok(())
443 }
444
445 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}