#![doc(html_root_url = "https://docs.smix.dev/smix-driver")]
use smix_error::{ExpectationFailure, FailureCode, FailureInit};
use smix_host_coord_resolver::{HostResolveError, resolve_to_norm_coord};
use smix_input::{KeyName, SwipeDirection};
use smix_screen::{
A11yNode, DEFAULT_VISIBLE_LIMIT, ScreenDescription, collect_visible_summaries, summarize_node,
};
use smix_selector::{Modifiers, Pattern, Selector, True, describe_selector, match_text_compiled};
use smix_selector_resolver::{
ResolverContext, resolve_selector, resolve_selector_all, resolve_selector_compiled,
};
use std::time::{Duration, Instant};
use tokio::time::sleep;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Orientation {
Portrait,
PortraitUpsideDown,
LandscapeLeft,
LandscapeRight,
}
impl Orientation {
pub fn as_wire(self) -> &'static str {
match self {
Self::Portrait => "portrait",
Self::PortraitUpsideDown => "portraitUpsideDown",
Self::LandscapeLeft => "landscapeLeft",
Self::LandscapeRight => "landscapeRight",
}
}
}
pub use smix_runner_client::{
HttpRunnerClient, IncludeScope, OcrFrame, RunnerScrollSelector, RunnerTransportError,
SystemPopup, TapMode,
};
const POLL_INTERVAL_MS: u64 = 250;
const TOTAL_TIMEOUT_MS: u64 = 5000;
const SCROLL_MAX_SWIPES: u32 = 30;
pub struct IosDriver {
runner: HttpRunnerClient,
}
impl IosDriver {
pub fn new(runner: HttpRunnerClient) -> Self {
IosDriver { runner }
}
pub fn runner(&self) -> &HttpRunnerClient {
&self.runner
}
pub fn runner_mut(&mut self) -> &mut HttpRunnerClient {
&mut self.runner
}
#[must_use]
pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
self.runner = self.runner.with_target_bundle_id(bundle);
self
}
#[must_use]
pub fn with_auto_activate(mut self, activate: bool) -> Self {
self.runner = self.runner.with_auto_activate(activate);
self
}
pub async fn tree(
&self,
include: Option<IncludeScope>,
) -> Result<A11yNode, ExpectationFailure> {
self.runner
.get_tree(include)
.await
.map_err(transport_to_failure)
}
pub async fn describe(&self) -> Result<ScreenDescription, ExpectationFailure> {
let tree = self.tree(None).await?;
Ok(ScreenDescription {
screenshot: None,
elements: collect_visible_summaries(&tree, DEFAULT_VISIBLE_LIMIT),
front_app: String::new(),
summary: String::new(),
captured_at: 0.0,
})
}
pub async fn find_one(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<Option<A11yNode>, ExpectationFailure> {
let tree = self.tree_with_retry(include).await?;
Ok(resolve_selector(&tree, selector).cloned())
}
pub async fn find_norm_coord(
&self,
selector: &Selector,
) -> Result<Option<(f64, f64)>, ExpectationFailure> {
let tree = self.tree_with_retry(None).await?;
match resolve_to_norm_coord(&tree, selector) {
Ok((nx, ny)) => Ok(Some((nx, ny))),
Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)".into(),
..Default::default()
})),
Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
}
}
pub async fn find_all(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<Vec<A11yNode>, ExpectationFailure> {
let tree = self.tree_with_retry(include).await?;
Ok(resolve_selector_all(&tree, selector)
.into_iter()
.cloned()
.collect())
}
pub async fn find(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<bool, ExpectationFailure> {
if can_use_find_route(selector) {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
let mut last_transport_err: Option<ExpectationFailure> = None;
loop {
match self.runner.find(selector, include).await {
Ok(present) => return Ok(present),
Err(e) => {
let failure = transport_to_failure(e);
if start.elapsed() >= timeout {
return Err(last_transport_err.unwrap_or(failure));
}
last_transport_err = Some(failure);
}
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
} else {
let tree = self.tree_with_retry(include).await?;
Ok(!resolve_selector_all(&tree, selector).is_empty())
}
}
async fn tree_with_retry(
&self,
include: Option<IncludeScope>,
) -> Result<A11yNode, ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
let mut last_transport_err: Option<ExpectationFailure> = None;
loop {
match self.tree(include).await {
Ok(tree) => return Ok(tree),
Err(e) => {
if start.elapsed() >= timeout {
return Err(last_transport_err.unwrap_or(e));
}
last_transport_err = Some(e);
}
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
}
pub async fn system_popups(
&self,
include: Option<IncludeScope>,
) -> Result<Vec<SystemPopup>, ExpectationFailure> {
self.runner
.system_popups(include)
.await
.map_err(transport_to_failure)
}
pub async fn system_popup_action(
&self,
popup_id: &str,
button_id: &str,
) -> Result<bool, ExpectationFailure> {
self.runner
.system_popup_action(popup_id, button_id)
.await
.map_err(transport_to_failure)
}
pub async fn tap(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
let (nx, ny) = loop {
let tree = self.tree_with_retry(include).await?;
match resolve_to_norm_coord(&tree, selector) {
Ok(coord) => break coord,
Err(HostResolveError::NotFound) => {
if start.elapsed() > timeout {
let visible = collect_visible_summaries(&tree, 10);
let target = base_text_or_id(selector);
let suggestions =
smix_error::build_suggestions(target.as_deref(), &visible);
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"element not found: {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
visible_elements: visible,
suggestions,
hint: Some(
"matched 0 nodes in the current a11y tree; check selector or wait for the screen to settle"
.into(),
),
..Default::default()
}));
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
continue;
}
Err(HostResolveError::EmptyMatchedFrame) => {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"matched node has empty/offscreen frame: {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
hint: Some(
"node bounds w*h == 0; element may be offscreen or hidden".into(),
),
..Default::default()
}));
}
Err(HostResolveError::UnknownAppFrame) => {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"tree bounds w/h ≤ 0 — unknown app frame: {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
hint: Some(
"runner returned a tree with empty app frame; app may not be foregrounded"
.into(),
),
..Default::default()
}));
}
Err(HostResolveError::CentroidOutOfFrame { nx, ny }) => {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"matched node centroid out of app frame: {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
hint: Some(format!(
"centroid (nx={:.3}, ny={:.3}) outside (0,1); element offscreen",
nx, ny
)),
..Default::default()
}));
}
}
};
self.runner
.tap_at_norm_coord(nx, ny)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn tap_with_mode(
&self,
selector: &Selector,
mode: TapMode,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
loop {
match self.runner.tap(selector, mode, include).await {
Ok(_result) => return Ok(()),
Err(e) => {
if start.elapsed() > timeout {
return Err(transport_to_failure(e));
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
continue;
}
}
}
}
pub async fn double_tap(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
loop {
match self.runner.double_tap(selector, include).await {
Ok(_result) => return Ok(()),
Err(e) => {
if start.elapsed() > timeout {
return Err(transport_to_failure(e));
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
continue;
}
}
}
}
pub async fn long_press(
&self,
selector: &Selector,
duration: Duration,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
let duration_ms = duration.as_millis().min(u64::MAX as u128) as u64;
loop {
match self.runner.long_press(selector, duration_ms, include).await {
Ok(_result) => return Ok(()),
Err(e) => {
if start.elapsed() > timeout {
return Err(transport_to_failure(e));
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
continue;
}
}
}
}
pub async fn set_orientation(
&self,
orientation: Orientation,
) -> Result<(), ExpectationFailure> {
self.runner
.set_orientation(orientation.as_wire())
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn fill(
&self,
selector: &Selector,
text: &str,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
if can_use_find_route(selector) {
self.chunked_fill_runner(selector, text, include).await
} else if matches!(selector, Selector::Focused { .. }) {
self.chunked_fill_runner(selector, text, include).await
} else {
self.tap(selector, include).await?;
sleep(Duration::from_millis(300)).await;
let focused = Selector::Focused {
focused: True(true),
};
self.chunked_fill_runner(&focused, text, include).await
}
}
async fn chunked_fill_runner(
&self,
selector: &Selector,
text: &str,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
const INTER_CHAR_PAUSE_MS: u64 = 50;
let chars: Vec<char> = text.chars().collect();
if chars.len() <= 1 {
return self
.runner
.fill(selector, text, include)
.await
.map_err(transport_to_failure)
.map(|_| ());
}
for (i, ch) in chars.iter().enumerate() {
let chunk = ch.to_string();
self.runner
.fill(selector, &chunk, include)
.await
.map_err(transport_to_failure)?;
if i + 1 < chars.len() {
sleep(Duration::from_millis(INTER_CHAR_PAUSE_MS)).await;
}
}
Ok(())
}
pub async fn clear(
&self,
selector: &Selector,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
if can_use_find_route(selector) {
self.runner
.clear(selector, include)
.await
.map_err(transport_to_failure)?;
} else {
self.tap(selector, include).await?;
sleep(Duration::from_millis(300)).await;
let focused = Selector::Focused {
focused: True(true),
};
self.runner
.clear(&focused, include)
.await
.map_err(transport_to_failure)?;
}
Ok(())
}
pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
self.runner
.press_key(key)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn scroll(
&self,
selector: &Selector,
direction: SwipeDirection,
) -> Result<(), ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_secs(20);
let Some(ctx) = ResolverContext::new(selector) else {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"scroll({}, '{}'): selector pattern failed to compile",
describe_selector(selector),
direction
),
selector: Some(selector.clone()),
hint: Some(
"regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
.into(),
),
..Default::default()
}));
};
for i in 0..=SCROLL_MAX_SWIPES {
let tree = self.tree_with_retry(None).await?;
if resolve_selector_compiled(&tree, selector, &ctx).is_some() {
return Ok(());
}
if i == SCROLL_MAX_SWIPES || start.elapsed() > timeout {
let visible = collect_visible_summaries(&tree, 10);
let target = base_text_or_id(selector);
let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"scroll({}, '{}'): element not visible after {} swipes",
describe_selector(selector),
direction,
SCROLL_MAX_SWIPES
),
selector: Some(selector.clone()),
visible_elements: visible,
suggestions,
..Default::default()
}));
}
self.runner
.swipe_once(direction)
.await
.map_err(transport_to_failure)?;
}
Ok(())
}
pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
self.runner
.swipe_once(direction)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
self.runner
.hide_keyboard()
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn back(&self) -> Result<(), ExpectationFailure> {
self.runner.back().await.map_err(transport_to_failure)?;
Ok(())
}
pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
self.runner
.tap_at_norm_coord(nx, ny)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
let ok = self
.runner
.tap_by_id(id)
.await
.map_err(transport_to_failure)?;
if !ok {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!("tap_by_id: element not found — id=\"{id}\""),
hint: Some(
"runner XCUIQuery returned no match; check id spelling or wait for screen to settle"
.into(),
),
..Default::default()
}));
}
Ok(())
}
pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
self.runner.webview_eval(js).await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("webview_eval: {e}"),
..Default::default()
})
})
}
pub async fn find_text_by_ocr(
&self,
text: &str,
locales: &[String],
recognition_level: &str,
) -> Result<Option<OcrFrame>, ExpectationFailure> {
self.runner
.find_text_by_ocr(text, locales, recognition_level)
.await
.map_err(transport_to_failure)
}
pub async fn swipe_at_norm_coord(
&self,
from: (f64, f64),
to: (f64, f64),
) -> Result<(), ExpectationFailure> {
self.runner
.swipe_at_norm_coord(from, to)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
self.runner
.foreground(bundle_id)
.await
.map_err(transport_to_failure)?;
Ok(())
}
pub async fn wait_for(
&self,
selector: &Selector,
timeout: Duration,
include: Option<IncludeScope>,
) -> Result<A11yNode, ExpectationFailure> {
let start = Instant::now();
let Some(ctx) = ResolverContext::new(selector) else {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::Timeout),
message: format!(
"waitFor({}): selector pattern failed to compile",
describe_selector(selector)
),
selector: Some(selector.clone()),
hint: Some(
"regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
.into(),
),
..Default::default()
}));
};
let mut last_transport_err: Option<ExpectationFailure> = None;
loop {
match self.tree(include).await {
Ok(tree) => {
if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
return Ok(node.clone());
}
if start.elapsed() >= timeout {
let visible = collect_visible_summaries(&tree, 10);
let target = base_text_or_id(selector);
let suggestions =
smix_error::build_suggestions(target.as_deref(), &visible);
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::Timeout),
message: format!(
"waitFor({}) timed out after {:?}",
describe_selector(selector),
timeout
),
selector: Some(selector.clone()),
visible_elements: visible,
suggestions,
..Default::default()
}));
}
last_transport_err = None;
}
Err(e) => {
if start.elapsed() >= timeout {
return Err(last_transport_err.unwrap_or(e));
}
last_transport_err = Some(e);
}
}
sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
}
}
pub async fn dispose(&self) -> Result<(), ExpectationFailure> {
Ok(())
}
}
fn transport_to_failure(e: RunnerTransportError) -> ExpectationFailure {
let (code, hint) = match &e {
RunnerTransportError::Unreachable { .. } => (
FailureCode::DriverError,
Some("start the runner first: bash scripts/smix-runner-health.sh".to_string()),
),
RunnerTransportError::AppUnavailable { target, reason, .. } => (
FailureCode::DriverError,
Some(format!(
"runner reports snapshot_unavailable — target={} reason={}. \
Fix by (a) `smix run --bundle-id <BUNDLE>` so the client sends \
App-Bundle-Id header, or (b) `smix run --activate` so the runner \
auto-activates the target before snapshot, or (c) foreground the \
target app before invocation.",
target.as_deref().unwrap_or("<unknown>"),
reason.as_deref().unwrap_or("<no reason>"),
)),
),
_ => (FailureCode::DriverError, None),
};
ExpectationFailure::new(FailureInit {
code: Some(code),
message: format!("{e}"),
hint,
..Default::default()
})
}
fn base_text_or_id(selector: &Selector) -> Option<String> {
match selector {
Selector::Text { text, .. } => match text {
Pattern::Text(s) => Some(s.clone()),
Pattern::Regex { regex, .. } => Some(regex.clone()),
},
Selector::Id { id, .. } => Some(id.clone()),
Selector::Label { label, .. } => Some(label.clone()),
Selector::Role { name, .. } => name.as_ref().map(|p| match p {
Pattern::Text(s) => s.clone(),
Pattern::Regex { regex, .. } => regex.clone(),
}),
Selector::Focused { .. } | Selector::Anchor { .. } => None,
Selector::LocalizedText { localized_text, .. } => {
localized_text
.get("en")
.or_else(|| localized_text.values().next())
.cloned()
}
Selector::OcrText { ocr_text, .. } => Some(ocr_text.clone()),
Selector::AnchorRelative { anchor, .. } => base_text_or_id(anchor),
Selector::Point { .. } => None,
Selector::Fallback { fallback } => fallback.first().and_then(base_text_or_id),
}
}
fn can_use_find_route(selector: &Selector) -> bool {
let Selector::Text { modifiers, .. } = selector else {
return false;
};
modifiers.near.is_none()
&& modifiers.below.is_none()
&& modifiers.above.is_none()
&& modifiers.left_of.is_none()
&& modifiers.right_of.is_none()
&& modifiers.inside.is_none()
&& modifiers.ancestor.is_none()
&& modifiers.nth.is_none()
&& modifiers.first.is_none()
&& modifiers.last.is_none()
}
#[doc(hidden)]
pub use smix_selector::Modifiers as _ModifiersReexport;
#[doc(hidden)]
pub use smix_selector::match_text_compiled as _match_text_compiled_reexport;
#[allow(dead_code)]
fn _silence_unused_imports() {
let _: fn(&A11yNode, &smix_selector::CompiledPattern) -> bool = match_text_compiled;
let _: Modifiers = Modifiers::default();
let _: ScreenDescription = ScreenDescription::default();
let _ = summarize_node;
}
mod android;
mod ios;
mod traits;
pub use android::AndroidDriver;
pub use traits::{Driver, Platform};
pub type SimctlDriver = IosDriver;