#[cfg(not(target_arch = "wasm32"))]
use anyhow::{anyhow, Result};
#[cfg(not(target_arch = "wasm32"))]
use base64::Engine;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "cmd")]
pub enum TestCommand {
Tap {
x: f32,
y: f32,
},
Drag {
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
steps: u32,
},
TapText {
text: String,
},
Scroll {
x: f32,
y: f32,
dx: f32,
dy: f32,
},
TypeText {
text: String,
},
PressKey {
key: String,
modifiers: u8,
},
Screenshot {
path: String,
},
CaptureScreenshot {},
GetText {},
GetTree {},
Wait {
ms: u64,
},
Pump {},
Quit {},
SimulateMouseMove {
x: f32,
y: f32,
},
SimulateRightClick {
x: f32,
y: f32,
},
SimulateResize {
width: u32,
height: u32,
},
}
#[derive(Debug, Clone)]
pub enum TestEvent {
MouseMove {
x: f32,
y: f32,
},
MouseDown {
x: f32,
y: f32,
button: u8,
}, MouseUp {
x: f32,
y: f32,
button: u8,
},
KeyDown {
key_code: String,
modifiers: u8,
},
KeyUp {
key_code: String,
modifiers: u8,
},
TextInput {
text: String,
},
Scroll {
x: f32,
y: f32,
dx: f32,
dy: f32,
},
Resize {
width: u32,
height: u32,
},
Screenshot {
path: String,
},
CaptureScreenshot,
GetText,
GetTree,
Pump,
Wake,
Quit,
TapText {
text: String,
},
Wait {
ms: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextItem {
pub text: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SemanticNode {
pub role: String,
pub label: Option<String>,
pub value: Option<String>,
pub focusable: bool,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status")]
pub enum TestResponse {
Ok {},
Text {
items: Vec<TextItem>,
},
Tree {
nodes: Vec<SemanticNode>,
},
Screenshot {
png_base64: String,
width: u32,
height: u32,
},
Error {
message: String,
},
}
#[cfg(not(target_arch = "wasm32"))]
pub struct LiveTestClient {
base_url: String,
}
#[cfg(not(target_arch = "wasm32"))]
impl LiveTestClient {
pub fn connect(port: u16) -> Self {
Self {
base_url: format!("http://127.0.0.1:{}", port),
}
}
pub fn wait_for_ready(&self, timeout_ms: u64) -> Result<()> {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_millis(timeout_ms);
loop {
match ureq::get(&format!("{}/health", self.base_url)).call() {
Ok(_) => return Ok(()),
Err(_) => {
if start.elapsed() > timeout {
return Err(anyhow!("timed out waiting for test server"));
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
}
}
fn send(&self, cmd: TestCommand) -> Result<TestResponse> {
let body = serde_json::to_string(&cmd)?;
let resp = ureq::post(&format!("{}/cmd", self.base_url))
.set("Content-Type", "application/json")
.send_string(&body)
.map_err(|e| anyhow!("request failed: {}", e))?;
let text = resp.into_string()?;
let response: TestResponse = serde_json::from_str(&text)?;
if let TestResponse::Error { message } = &response {
return Err(anyhow!("server error: {}", message));
}
Ok(response)
}
pub fn tap(&self, x: f32, y: f32) -> Result<()> {
self.send(TestCommand::Tap { x, y })?;
Ok(())
}
pub fn tap_text(&self, text: &str) -> Result<()> {
self.pump()?;
self.send(TestCommand::TapText {
text: text.to_string(),
})?;
self.pump()?;
Ok(())
}
pub fn drag(
&self,
start_x: f32,
start_y: f32,
end_x: f32,
end_y: f32,
steps: u32,
) -> Result<()> {
self.send(TestCommand::Drag {
start_x,
start_y,
end_x,
end_y,
steps,
})?;
self.pump()?;
Ok(())
}
pub fn scroll(&self, x: f32, y: f32, dx: f32, dy: f32) -> Result<()> {
self.send(TestCommand::Scroll { x, y, dx, dy })?;
Ok(())
}
pub fn press_key(&self, key: &str, modifiers: u8) -> Result<()> {
self.send(TestCommand::PressKey {
key: key.to_string(),
modifiers,
})?;
self.pump()?;
Ok(())
}
pub fn type_text(&self, text: &str) -> Result<()> {
self.send(TestCommand::TypeText {
text: text.to_string(),
})?;
Ok(())
}
pub fn screenshot(&self, path: &str) -> Result<()> {
match self.send(TestCommand::CaptureScreenshot {})? {
TestResponse::Screenshot {
png_base64,
width: _,
height: _,
} => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(png_base64)
.map_err(|e| anyhow!("invalid screenshot payload: {}", e))?;
std::fs::write(path, bytes)?;
Ok(())
}
other => Err(anyhow!(
"unexpected response to CaptureScreenshot: {:?}",
other
)),
}
}
pub fn get_text(&self) -> Result<Vec<TextItem>> {
match self.send(TestCommand::GetText {})? {
TestResponse::Text { items } => Ok(items),
other => Err(anyhow!("unexpected response: {:?}", other)),
}
}
pub fn get_tree(&self) -> Result<Vec<SemanticNode>> {
match self.send(TestCommand::GetTree {})? {
TestResponse::Tree { nodes } => Ok(nodes),
other => Err(anyhow!("unexpected response: {:?}", other)),
}
}
pub fn wait(&self, ms: u64) -> Result<()> {
self.send(TestCommand::Wait { ms })?;
Ok(())
}
pub fn pump(&self) -> Result<()> {
self.send(TestCommand::Pump {})?;
Ok(())
}
pub fn quit(&self) -> Result<()> {
let _ = self.send(TestCommand::Quit {});
Ok(())
}
pub fn simulate_mouse_move(&self, x: f32, y: f32) -> Result<()> {
self.send(TestCommand::SimulateMouseMove { x, y })?;
Ok(())
}
pub fn right_click(&self, x: f32, y: f32) -> Result<()> {
self.send(TestCommand::SimulateRightClick { x, y })?;
Ok(())
}
pub fn simulate_resize(&self, width: u32, height: u32) -> Result<()> {
self.send(TestCommand::SimulateResize { width, height })?;
Ok(())
}
pub fn tap_text_and_wait(&self, text: &str, ms: u64) -> Result<()> {
self.tap_text(text)?;
self.wait(ms)?;
Ok(())
}
pub fn assert_text_visible(&self, needle: &str) -> Result<()> {
let items = self.get_text()?;
let found = items.iter().any(|t| t.text.contains(needle));
if !found {
let all: Vec<&str> = items.iter().map(|t| t.text.as_str()).collect();
return Err(anyhow!(
"expected '{}' to be visible, found: {:?}",
needle,
&all[..all.len().min(20)]
));
}
Ok(())
}
pub fn assert_text_not_visible(&self, needle: &str) -> Result<()> {
let items = self.get_text()?;
let found = items.iter().any(|t| t.text.contains(needle));
if found {
return Err(anyhow!("expected '{}' to NOT be visible", needle));
}
Ok(())
}
}