#![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: front_app_of(&tree),
summary: String::new(),
captured_at: captured_at_unix_millis(),
})
}
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_on_screen(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?;
let matched = resolve_selector_all(&tree, selector);
if matched.is_empty() {
return Ok(false);
}
Ok(self.confirm_on_screen(&matched, include).await)
}
}
async fn confirm_on_screen(
&self,
matched: &[&A11yNode],
include: Option<IncludeScope>,
) -> bool {
let mut had_handle = false;
for node in matched.iter().take(3) {
let handle = node
.identifier
.as_deref()
.filter(|s| !s.is_empty())
.or_else(|| node.label.as_deref().filter(|s| !s.is_empty()));
let Some(handle) = handle else { continue };
had_handle = true;
let probe = Selector::Text {
text: Pattern::Text(handle.to_string()),
modifiers: smix_selector::Modifiers::default(),
};
match self.runner.find_on_screen(&probe, include).await {
Ok(true) => return true,
Ok(false) => continue,
Err(_) => return true,
}
}
!had_handle
}
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<ActOutcome, ExpectationFailure> {
let start = Instant::now();
let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
let (nx, ny, aimed) = loop {
let tree = self.tree_with_retry(include).await?;
match resolve_to_norm_coord(&tree, selector) {
Ok(coord) => {
let aimed = resolve_selector(&tree, selector).map(|n| HitElement {
identifier: n.identifier.clone().unwrap_or_default(),
label: n.label.clone().unwrap_or_default(),
frame: (n.bounds.x, n.bounds.y, n.bounds.w, n.bounds.h),
});
break (coord.0, coord.1, aimed);
}
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()
}));
}
}
};
let landed = self
.runner
.tap_at_norm_coord(nx, ny)
.await
.map_err(transport_to_failure)?;
let chain: Vec<HitElement> = landed
.chain
.iter()
.map(|e| HitElement {
identifier: e.identifier.clone(),
label: e.label.clone(),
frame: (e.frame.x, e.frame.y, e.frame.w, e.frame.h),
})
.collect();
let Some(aimed) = aimed else {
return Ok(ActOutcome {
target: None,
observed: chain,
verdict: ActVerdict::Unconfirmable(
"the selector resolved to a coordinate but not to a node, so \
there is nothing to compare the tapped point against"
.into(),
),
});
};
let verdict = if chain.is_empty() {
ActVerdict::Unconfirmable(
"the runner reported no elements at the tapped point; it may \
predate the field that carries them"
.into(),
)
} else {
tap_landed_within(&aimed, &chain)
};
if let ActVerdict::Missed(why) = &verdict {
if tap_mismatch_is_fatal() {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::TapMissed),
message: format!("tap did not land where it aimed: {why}"),
selector: Some(selector.clone()),
hint: Some(
"the screen moved between the tree fetch and the tap; \
wait for it to settle first. Set \
SMIX_TAP_HIT_MISMATCH=warn to downgrade this to a \
warning while migrating a suite."
.into(),
),
..Default::default()
}));
}
eprintln!("smix: warning: tap did not land where it aimed: {why}");
}
Ok(ActOutcome {
target: Some(aimed),
observed: chain,
verdict,
})
}
pub async fn tap_burst(
&self,
selector: &Selector,
times: u32,
interval_ms: Option<u32>,
hold_ms: Option<u32>,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
let tree = self.tree_with_retry(include).await?;
let (nx, ny) = resolve_to_norm_coord(&tree, selector).map_err(|_| {
let visible = collect_visible_summaries(&tree, 10);
let target = base_text_or_id(selector);
let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!("element not found: {}", describe_selector(selector)),
selector: Some(selector.clone()),
visible_elements: visible,
suggestions,
..Default::default()
})
})?;
self.runner
.tap_at_norm_coord_burst(nx, ny, times, interval_ms, hold_ms)
.await
.map(|_| ())
.map_err(transport_to_failure)
}
pub async fn tap_with_mode(
&self,
selector: &Selector,
mode: TapMode,
include: Option<IncludeScope>,
) -> Result<(), ExpectationFailure> {
require_runner_resolvable_selector(selector, "/tap")?;
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) => {
let permanent = matches!(
&e,
smix_runner_client::RunnerTransportError::NonSuccessStatus {
status, ..
} if (400..500).contains(status) && *status != 404
);
if permanent || 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> {
require_runner_resolvable_selector(selector, "/double-tap")?;
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) => {
let permanent = matches!(
&e,
smix_runner_client::RunnerTransportError::NonSuccessStatus {
status, ..
} if (400..500).contains(status) && *status != 404
);
if permanent || 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<PressTiming, ExpectationFailure> {
require_runner_resolvable_selector(selector, "/long-press")?;
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 {
let sent_ms = host_now_ms();
match self.runner.long_press(selector, duration_ms, include).await {
Ok(result) => {
return Ok(PressTiming {
sent_ms,
received_ms: host_now_ms(),
latest_down_offset_ms: result.latest_down_offset_ms,
earliest_up_offset_ms: result.earliest_up_offset_ms,
handler_wall_ms: result.handler_wall_ms,
});
}
Err(e) => {
let permanent = matches!(
&e,
smix_runner_client::RunnerTransportError::NonSuccessStatus {
status, ..
} if (400..500).contains(status) && *status != 404
);
if permanent || 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 let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
let matched = [node];
if self.confirm_on_screen(&matched, None).await {
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;
let mut tree_hit_offscreen = false;
loop {
match self.tree(include).await {
Ok(tree) => {
if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
let matched = [node];
if self.confirm_on_screen(&matched, include).await {
return Ok(node.clone());
}
tree_hit_offscreen = true;
}
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);
let hint = if tree_hit_offscreen {
Some(
"the a11y tree matched this selector but the LIVE \
on-screen check refuted it every time — the element \
exists with a stale/drifted snapshot frame (typically \
below the fold on iOS 26.5 + RN Fabric). Use \
scrollUntilVisible to bring it into the viewport \
first, or an ocrText tier to assert by pixels."
.to_string(),
)
} else {
None
};
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,
hint,
..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(())
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct ActOutcome {
pub target: Option<HitElement>,
pub observed: Vec<HitElement>,
pub verdict: ActVerdict,
}
impl ActOutcome {
#[must_use]
pub fn unjudged() -> Self {
ActOutcome {
target: None,
observed: Vec::new(),
verdict: ActVerdict::Unconfirmable(
"this path dispatches without resolving a target element".into(),
),
}
}
}
fn tap_mismatch_is_fatal() -> bool {
!std::env::var("SMIX_TAP_HIT_MISMATCH")
.map(|v| v.eq_ignore_ascii_case("warn"))
.unwrap_or(false)
}
#[derive(Clone, Debug, PartialEq)]
pub struct HitElement {
pub identifier: String,
pub label: String,
pub frame: (f64, f64, f64, f64),
}
#[derive(Clone, Debug, PartialEq)]
pub enum ActVerdict {
Confirmed,
Missed(String),
Unconfirmable(String),
}
const FRAME_TOLERANCE_PT: f64 = 1.0;
#[must_use]
pub fn host_now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PressTiming {
pub sent_ms: u64,
pub received_ms: u64,
pub latest_down_offset_ms: u64,
pub earliest_up_offset_ms: u64,
pub handler_wall_ms: u64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CaptureSpan {
pub start_ms: u64,
pub end_ms: u64,
}
#[derive(Clone, Debug, PartialEq)]
pub enum FramePlacement {
DuringPress,
Outside(String),
Uncertain(String),
}
impl PressTiming {
fn transit_ambiguity_ms(&self) -> u64 {
self.received_ms
.saturating_sub(self.sent_ms)
.saturating_sub(self.handler_wall_ms)
}
fn certainly_held_ms(&self) -> Option<(u64, u64)> {
let start = self.sent_ms + self.transit_ambiguity_ms() + self.latest_down_offset_ms;
let end = self.sent_ms + self.earliest_up_offset_ms;
(start < end).then_some((start, end))
}
}
impl PressTiming {
#[must_use]
pub fn unplaceable() -> Self {
PressTiming {
sent_ms: 0,
received_ms: 0,
latest_down_offset_ms: 0,
earliest_up_offset_ms: 0,
handler_wall_ms: 0,
}
}
}
#[must_use]
pub fn press_frame_placement(press: &PressTiming, frame: &CaptureSpan) -> FramePlacement {
let held_ms = press
.earliest_up_offset_ms
.saturating_sub(press.latest_down_offset_ms);
let ambiguity = press.transit_ambiguity_ms();
let Some((held_from, held_to)) = press.certainly_held_ms() else {
return FramePlacement::Uncertain(format!(
"the press was held for {held_ms}ms but {ambiguity}ms of the \
round trip is unaccounted for, so no instant on this host's \
clock is certainly inside it — hold for longer than {ambiguity}ms"
));
};
if frame.start_ms >= held_from && frame.end_ms <= held_to {
return FramePlacement::DuringPress;
}
if frame.start_ms >= held_to {
return FramePlacement::Outside(format!(
"the capture started {}ms after the touch could still have been \
down — the press was {held_ms}ms and a capture takes around \
230ms, so it has to start earlier or the press has to be longer",
frame.start_ms - held_to
));
}
if frame.end_ms <= held_from {
return FramePlacement::Outside(format!(
"the capture finished {}ms before the touch was certainly down",
held_from - frame.end_ms
));
}
FramePlacement::Uncertain(format!(
"the capture ran {}..{} and the touch was certainly down only over \
{held_from}..{held_to}, so its pixels could be from either side of \
the boundary",
frame.start_ms, frame.end_ms
))
}
pub fn tap_landed_within(aimed: &HitElement, chain: &[HitElement]) -> ActVerdict {
if chain.is_empty() {
return ActVerdict::Missed(format!(
"aimed at {} and the tapped point held nothing — the element \
moved between the tree fetch and the tap, or its frame was \
stale",
describe_hit(aimed)
));
}
if chain.iter().any(|c| same_element(aimed, c)) {
return ActVerdict::Confirmed;
}
if aimed.identifier.is_empty() && aimed.label.is_empty() {
return ActVerdict::Unconfirmable(format!(
"the element aimed at carries neither an identifier nor a \
label, so it cannot be looked for among the {} element(s) \
at the tapped point",
chain.len()
));
}
ActVerdict::Missed(format!(
"aimed at {} but the tapped point is inside {} instead",
describe_hit(aimed),
chain
.iter()
.map(describe_hit)
.collect::<Vec<_>>()
.join(", ")
))
}
fn same_element(a: &HitElement, b: &HitElement) -> bool {
if !a.identifier.is_empty() && !b.identifier.is_empty() {
return a.identifier == b.identifier;
}
if !a.label.is_empty() && !b.label.is_empty() {
return a.label == b.label;
}
if a.identifier.is_empty()
&& a.label.is_empty()
&& b.identifier.is_empty()
&& b.label.is_empty()
{
let close = |x: f64, y: f64| (x - y).abs() <= FRAME_TOLERANCE_PT;
return close(a.frame.0, b.frame.0)
&& close(a.frame.1, b.frame.1)
&& close(a.frame.2, b.frame.2)
&& close(a.frame.3, b.frame.3);
}
false
}
fn describe_hit(e: &HitElement) -> String {
if !e.identifier.is_empty() {
format!("id={}", e.identifier)
} else if !e.label.is_empty() {
format!("label={:?}", e.label)
} else {
format!(
"an unnamed element at ({:.0},{:.0} {:.0}x{:.0})",
e.frame.0, e.frame.1, e.frame.2, e.frame.3
)
}
}
#[doc(hidden)]
pub fn require_runner_resolvable_selector(
selector: &Selector,
route: &str,
) -> Result<(), ExpectationFailure> {
let default_modifiers = smix_selector::Modifiers::default();
let ok = match selector {
Selector::Text { text, modifiers } => {
matches!(text, smix_selector::Pattern::Text(_)) && *modifiers == default_modifiers
}
Selector::Id { modifiers, .. } | Selector::Label { modifiers, .. } => {
*modifiers == default_modifiers
}
_ => false,
};
if ok {
return Ok(());
}
Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"{route} resolves text, id and label selectors runner-side; it \
does not take regex patterns, roles, or spatial/index modifiers. \
Those resolve against the full tree, which only the host has — \
use the default tap (host-side resolve) for them."
),
..Default::default()
}))
}
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 { text, modifiers } = selector else {
return false;
};
if !matches!(text, Pattern::Text(_)) {
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};
#[must_use]
pub fn front_app_of(tree: &A11yNode) -> Option<String> {
tree.identifier
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn captured_at_unix_millis() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as f64)
.unwrap_or(0.0)
}
pub type SimctlDriver = IosDriver;
#[cfg(test)]
mod runner_resolvable_tests {
use super::*;
use smix_selector::{Modifiers, Pattern, Selector};
#[test]
fn runner_resolvable_accepts_plain_text() {
let sel = Selector::Text {
text: Pattern::Text("Sign In".into()),
modifiers: Modifiers::default(),
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
}
#[test]
fn runner_resolvable_accepts_id() {
let sel = Selector::Id {
id: "btn-login".into(),
modifiers: Modifiers::default(),
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
}
#[test]
fn runner_resolvable_accepts_label() {
let sel = Selector::Label {
label: "Sign In".into(),
modifiers: Modifiers::default(),
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
}
#[test]
fn runner_resolvable_rejects_regex_text() {
let sel = Selector::Text {
text: Pattern::Regex {
regex: "^Sign".into(),
flags: "i".into(),
},
modifiers: Modifiers::default(),
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
}
#[test]
fn runner_resolvable_rejects_role() {
let sel = Selector::Role {
role: smix_selector::Role::Button,
name: None,
modifiers: Modifiers::default(),
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
}
#[test]
fn runner_resolvable_rejects_index_modifier() {
let sel = Selector::Id {
id: "row".into(),
modifiers: Modifiers {
nth: Some(2),
..Modifiers::default()
},
};
assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
}
}
#[cfg(test)]
mod describe_meta_tests {
use super::*;
use smix_screen::A11yNode;
fn node_with_identifier(id: Option<&str>) -> A11yNode {
A11yNode {
raw_type: "application".into(),
element_type_raw: 1,
role: None,
identifier: id.map(str::to_string),
label: None,
title: None,
placeholder_value: None,
value: None,
text: None,
bounds: smix_screen::Rect {
x: 0.0,
y: 0.0,
w: 390.0,
h: 844.0,
},
enabled: true,
selected: false,
has_focus: false,
visible: true,
children: vec![],
}
}
#[test]
fn describe_meta_front_app_reads_tree_root_identifier() {
let tree = node_with_identifier(Some("com.apple.Preferences"));
assert_eq!(
front_app_of(&tree).as_deref(),
Some("com.apple.Preferences")
);
}
#[test]
fn describe_meta_front_app_is_none_without_root_identifier() {
assert_eq!(front_app_of(&node_with_identifier(None)), None);
assert_eq!(front_app_of(&node_with_identifier(Some(""))), None);
}
#[test]
fn describe_meta_captured_at_is_unix_millis() {
assert!(captured_at_unix_millis() > 1_767_225_600_000.0);
}
#[test]
fn describe_meta_summary_is_not_produced_here() {
assert_eq!(smix_screen::ScreenDescription::default().summary, "");
}
}