#[cfg(not(target_arch = "wasm32"))]
use anyhow::{anyhow, Result};
#[cfg(not(target_arch = "wasm32"))]
use base64::Engine;
use serde::{Deserialize, Serialize};
#[cfg(not(target_arch = "wasm32"))]
pub mod browser;
#[cfg(not(target_arch = "wasm32"))]
pub use browser::{
detect_chrome, run_browser_smoke, BrowserSmokeMode, BrowserSmokeReport, BrowserTestOptions,
};
#[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,
},
ResolveSelector {
query: SelectorQuery,
},
TapSelector {
query: SelectorQuery,
},
ActivateSelector {
query: SelectorQuery,
},
FocusSelector {
query: SelectorQuery,
},
HoverSelector {
query: SelectorQuery,
},
RightClickSelector {
query: SelectorQuery,
},
ScrollIntoView {
query: SelectorQuery,
},
FillText {
query: SelectorQuery,
text: String,
},
ClearText {
query: SelectorQuery,
},
Toggle {
query: SelectorQuery,
},
SelectOption {
query: SelectorQuery,
},
WaitForSelector {
query: SelectorQuery,
timeout_ms: u64,
},
WaitForVisible {
query: SelectorQuery,
timeout_ms: u64,
},
WaitForEnabled {
query: SelectorQuery,
timeout_ms: u64,
},
WaitForDisabled {
query: SelectorQuery,
timeout_ms: u64,
},
WaitForValue {
query: SelectorQuery,
value: String,
timeout_ms: u64,
},
WaitForText {
text: String,
timeout_ms: u64,
},
WaitForGone {
query: SelectorQuery,
timeout_ms: u64,
},
Scroll {
x: f32,
y: f32,
dx: f32,
dy: f32,
},
TypeText {
text: String,
},
ImePreedit {
text: String,
cursor_start: Option<usize>,
cursor_end: Option<usize>,
},
ImeCommit {
text: String,
},
ImeCancel {},
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,
},
ImePreedit {
text: String,
cursor: Option<(usize, usize)>,
},
ImeCommit {
text: String,
},
ImeCancel,
Scroll {
x: f32,
y: f32,
dx: f32,
dy: f32,
},
Resize {
width: u32,
height: u32,
},
Screenshot {
path: String,
response_tx: TestResponseSender,
},
CaptureScreenshot {
response_tx: TestResponseSender,
},
GetText {
response_tx: TestResponseSender,
},
GetTree {
response_tx: TestResponseSender,
},
Pump {
response_tx: TestResponseSender,
},
Wake,
Quit,
TapText {
text: String,
response_tx: TestResponseSender,
},
ResolveSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
TapSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
ActivateSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
FocusSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
HoverSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
RightClickSelector {
query: SelectorQuery,
response_tx: TestResponseSender,
},
ScrollIntoView {
query: SelectorQuery,
response_tx: TestResponseSender,
},
FillText {
query: SelectorQuery,
text: String,
response_tx: TestResponseSender,
},
ClearText {
query: SelectorQuery,
response_tx: TestResponseSender,
},
Toggle {
query: SelectorQuery,
response_tx: TestResponseSender,
},
SelectOption {
query: SelectorQuery,
response_tx: TestResponseSender,
},
Wait {
ms: u64,
response_tx: TestResponseSender,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Selector {
SemanticIdentifier { identifier: String },
WidgetId { widget_id: String },
TestId { test_id: String },
AccessibilityIdentifier { identifier: String },
RoleLabel { role: String, label: String },
Label { label: String },
}
impl Selector {
pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
Self::SemanticIdentifier {
identifier: identifier.into(),
}
}
pub fn widget_id(widget_id: impl Into<String>) -> Self {
Self::WidgetId {
widget_id: widget_id.into(),
}
}
pub fn test_id(test_id: impl Into<String>) -> Self {
Self::TestId {
test_id: test_id.into(),
}
}
pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
Self::AccessibilityIdentifier {
identifier: identifier.into(),
}
}
pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
Self::RoleLabel {
role: role.into(),
label: label.into(),
}
}
pub fn label(label: impl Into<String>) -> Self {
Self::Label {
label: label.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SelectorQuery {
pub selector: Selector,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<Box<SelectorQuery>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index: Option<usize>,
#[serde(default)]
pub include_hidden: bool,
}
impl SelectorQuery {
pub fn new(selector: Selector) -> Self {
Self {
selector,
scope: None,
index: None,
include_hidden: false,
}
}
pub fn semantic_identifier(identifier: impl Into<String>) -> Self {
Self::new(Selector::semantic_identifier(identifier))
}
pub fn widget_id(widget_id: impl Into<String>) -> Self {
Self::new(Selector::widget_id(widget_id))
}
pub fn test_id(test_id: impl Into<String>) -> Self {
Self::new(Selector::test_id(test_id))
}
pub fn accessibility_identifier(identifier: impl Into<String>) -> Self {
Self::new(Selector::accessibility_identifier(identifier))
}
pub fn role_label(role: impl Into<String>, label: impl Into<String>) -> Self {
Self::new(Selector::role_label(role, label))
}
pub fn label(label: impl Into<String>) -> Self {
Self::new(Selector::label(label))
}
pub fn scoped(mut self, scope: SelectorQuery) -> Self {
self.scope = Some(Box::new(scope));
self
}
pub fn index(mut self, index: usize) -> Self {
self.index = Some(index);
self
}
pub fn include_hidden(mut self) -> Self {
self.include_hidden = true;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Bounds {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VisibilityState {
FullyVisible,
PartiallyVisible,
Hidden,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SelectorFailureKind {
NoMatch,
Ambiguous,
FoundButNotVisible,
Disabled,
ReadOnly,
UnsupportedAction,
Timeout,
StaleFrame,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectorCandidate {
pub node: SemanticNode,
pub rejected_reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SelectorFailure {
pub kind: SelectorFailureKind,
pub selector: SelectorQuery,
pub candidates: Vec<SelectorCandidate>,
pub message: String,
}
#[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 identifier: Option<String>,
pub widget_id: String,
pub stable_node_id: String,
pub parent: Option<String>,
pub children: Vec<String>,
pub role: String,
pub label: Option<String>,
pub value: Option<String>,
pub value_present: bool,
pub focusable: bool,
pub disabled: bool,
pub read_only: bool,
pub checked: Option<bool>,
pub actions: Vec<String>,
pub text_selection: Option<(usize, usize)>,
pub masked: bool,
pub scrollable_x: bool,
pub scrollable_y: bool,
pub logical_bounds: Bounds,
pub visible_bounds: Option<Bounds>,
pub visibility: VisibilityState,
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,
},
SelectorResolved {
node: SemanticNode,
},
SelectorError {
failure: SelectorFailure,
},
Error {
message: String,
},
}
pub type TestResponseSender = std::sync::mpsc::Sender<TestResponse>;
#[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));
}
if let TestResponse::SelectorError { failure } = &response {
return Err(anyhow!("selector error: {}", failure.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 tap_text_without_pump(&self, text: &str) -> Result<()> {
self.send(TestCommand::TapText {
text: text.to_string(),
})?;
Ok(())
}
pub fn resolve_selector(&self, query: SelectorQuery) -> Result<SemanticNode> {
match self.send(TestCommand::ResolveSelector { query })? {
TestResponse::SelectorResolved { node } => Ok(node),
other => Err(anyhow!(
"unexpected response to ResolveSelector: {:?}",
other
)),
}
}
pub fn scroll_into_view(&self, query: SelectorQuery) -> Result<SemanticNode> {
let node = match self.send(TestCommand::ScrollIntoView { query })? {
TestResponse::SelectorResolved { node } => node,
other => {
return Err(anyhow!(
"unexpected response to ScrollIntoView: {:?}",
other
))
}
};
self.pump()?;
Ok(node)
}
pub fn tap_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::TapSelector { query })?;
self.pump()?;
Ok(())
}
pub fn tap_semantic_identifier(&self, identifier: &str) -> Result<()> {
self.tap_selector(SelectorQuery::semantic_identifier(identifier))
}
pub fn activate_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::ActivateSelector { query })?;
self.pump()?;
Ok(())
}
pub fn focus_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::FocusSelector { query })?;
self.pump()?;
Ok(())
}
pub fn hover_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::HoverSelector { query })?;
self.pump()?;
Ok(())
}
pub fn right_click_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::RightClickSelector { query })?;
self.pump()?;
Ok(())
}
pub fn fill_text_selector(&self, query: SelectorQuery, text: &str) -> Result<()> {
self.pump()?;
self.send(TestCommand::FillText {
query,
text: text.to_string(),
})?;
self.pump()?;
Ok(())
}
pub fn fill_text_semantic_identifier(&self, identifier: &str, text: &str) -> Result<()> {
self.fill_text_selector(SelectorQuery::semantic_identifier(identifier), text)
}
pub fn clear_text_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::ClearText { query })?;
self.pump()?;
Ok(())
}
pub fn toggle_selector(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::Toggle { query })?;
self.pump()?;
Ok(())
}
pub fn select_option(&self, query: SelectorQuery) -> Result<()> {
self.pump()?;
self.send(TestCommand::SelectOption { query })?;
self.pump()?;
Ok(())
}
pub fn wait_for_selector(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForSelector { query, timeout_ms })?;
Ok(())
}
pub fn wait_for_visible(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForVisible { query, timeout_ms })?;
Ok(())
}
pub fn wait_for_enabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForEnabled { query, timeout_ms })?;
Ok(())
}
pub fn wait_for_disabled(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForDisabled { query, timeout_ms })?;
Ok(())
}
pub fn wait_for_value(&self, query: SelectorQuery, value: &str, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForValue {
query,
value: value.to_string(),
timeout_ms,
})?;
Ok(())
}
pub fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForText {
text: text.to_string(),
timeout_ms,
})?;
Ok(())
}
pub fn wait_for_gone(&self, query: SelectorQuery, timeout_ms: u64) -> Result<()> {
self.send(TestCommand::WaitForGone { query, timeout_ms })?;
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 ime_preedit(&self, text: &str, cursor: Option<(usize, usize)>) -> Result<()> {
self.send(TestCommand::ImePreedit {
text: text.to_string(),
cursor_start: cursor.map(|range| range.0),
cursor_end: cursor.map(|range| range.1),
})?;
self.pump()?;
Ok(())
}
pub fn ime_commit(&self, text: &str) -> Result<()> {
self.send(TestCommand::ImeCommit {
text: text.to_string(),
})?;
self.pump()?;
Ok(())
}
pub fn ime_cancel(&self) -> Result<()> {
self.send(TestCommand::ImeCancel {})?;
self.pump()?;
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(())
}
}