#![doc(html_root_url = "https://docs.smix.dev/smix-sdk")]
pub(crate) mod screenshot_hash;
pub mod issued_ledger;
pub use issued_ledger::{IssuedAction, IssuedKind, IssuedLedger};
pub mod device_control;
pub mod ios_device;
pub use device_control::{DeviceControl, Permission};
pub use ios_device::IosDeviceControl;
pub mod android_device;
pub use android_device::AndroidDeviceControl;
pub mod capsule;
pub use capsule::{
CapsuleReconciliation, DEFAULT_RECONCILE_WINDOW_MS, FOCUS_CHANGE_RAW_CODE, reconcile,
};
use std::time::Duration;
fn now_ms() -> f64 {
chrono::Utc::now().timestamp_millis() as f64
}
pub use smix_driver::{
HttpRunnerClient, IncludeScope, OcrFrame, RunnerScrollSelector, RunnerTransportError,
SimctlDriver, SystemPopup, TapMode,
};
pub use smix_error::{
ExpectationFailure, FailureCode, FailureInit, build_suggestions, edit_distance, similarity,
};
pub use smix_input::{KeyName, SwipeDirection};
pub use smix_screen::{
A11yNode, Bounds, ElementSummary, Rect, Role, ScreenDescription, collect_visible_summaries,
is_visible_enough, summarize_node, visible_area,
};
pub use smix_selector::{
AnchorBox, IndexModifiers, Modifiers, Pattern, Selector, True, describe_selector, match_text,
match_text_compiled,
};
pub use smix_simctl::{Appearance, LaunchResult, SimctlClient, SimctlError, SimctlPermission};
pub fn assert_screenshot_inner(
png_bytes: &[u8],
baseline_path: &std::path::Path,
max_hamming: u32,
strict: bool,
) -> Result<AssertScreenshotOutcome, ExpectationFailure> {
use std::io::ErrorKind;
let baseline_bytes = match std::fs::read(baseline_path) {
Ok(b) => b,
Err(e) if e.kind() == ErrorKind::NotFound => {
if strict {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"assert_screenshot: baseline missing at {} and SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD=1 set; record a baseline first",
baseline_path.display()
),
suggestions: vec![
"Run once without SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD to auto-record"
.into(),
],
..Default::default()
}));
}
if let Some(parent) = baseline_path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"assert_screenshot: failed to create baseline parent dir {}: {e}",
parent.display()
),
..Default::default()
})
})?;
}
std::fs::write(baseline_path, png_bytes).map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"assert_screenshot: failed to write baseline {}: {e}",
baseline_path.display()
),
..Default::default()
})
})?;
return Ok(AssertScreenshotOutcome::Recorded {
path: baseline_path.to_path_buf(),
});
}
Err(e) => {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!(
"assert_screenshot: failed to read baseline {}: {e}",
baseline_path.display()
),
..Default::default()
}));
}
};
let h_current = screenshot_hash::compute_dhash(png_bytes)?;
let h_baseline = screenshot_hash::compute_dhash(&baseline_bytes)?;
let hamming = screenshot_hash::hamming_distance(h_current, h_baseline);
if hamming <= max_hamming {
Ok(AssertScreenshotOutcome::Matched { hamming })
} else {
Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::AssertionFailed),
message: format!(
"assertScreenshot: dhash hamming distance {hamming} exceeds threshold {max_hamming} (baseline {})",
baseline_path.display()
),
suggestions: vec![
"Re-record baseline (delete the file) if the UI intentionally changed".into(),
"Or pin/wait for animations to settle before assertScreenshot".into(),
],
..Default::default()
}))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssertScreenshotOutcome {
Recorded {
path: std::path::PathBuf,
},
Matched {
hamming: u32,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum MaestroOrientation {
Portrait,
PortraitUpsideDown,
LandscapeLeft,
LandscapeRight,
}
impl MaestroOrientation {
pub fn to_driver(self) -> smix_driver::Orientation {
match self {
Self::Portrait => smix_driver::Orientation::Portrait,
Self::PortraitUpsideDown => smix_driver::Orientation::PortraitUpsideDown,
Self::LandscapeLeft => smix_driver::Orientation::LandscapeLeft,
Self::LandscapeRight => smix_driver::Orientation::LandscapeRight,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PermissionAction {
Grant,
Revoke,
Reset,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LaunchAppOptions {
pub bundle_id: String,
pub clear_state: bool,
pub clear_keychain: bool,
pub arguments: Vec<String>,
pub permissions: Vec<(SimctlPermission, PermissionAction)>,
pub app_path: Option<String>,
}
#[must_use]
pub fn text<S: Into<String>>(s: S) -> Selector {
Selector::Text {
text: Pattern::text(s),
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn text_regex<S: Into<String>>(p: S) -> Selector {
Selector::Text {
text: Pattern::regex(p),
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn id<S: Into<String>>(s: S) -> Selector {
Selector::Id {
id: s.into(),
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn label<S: Into<String>>(s: S) -> Selector {
Selector::Label {
label: s.into(),
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn role(r: Role) -> Selector {
Selector::Role {
role: r,
name: None,
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn role_named<S: Into<String>>(r: Role, name: S) -> Selector {
Selector::Role {
role: r,
name: Some(Pattern::text(name)),
modifiers: Modifiers::default(),
}
}
#[must_use]
pub fn focused() -> Selector {
Selector::Focused {
focused: True(true),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LaunchFreshOp {
Terminate,
Uninstall,
Install(String),
PrivacyResetAll,
SandboxClearInPlace(String),
KeychainReset,
Launch,
}
#[must_use]
pub fn plan_launch_fresh_calls(
clear_state: bool,
clear_keychain: bool,
app_path: Option<&str>,
) -> (Vec<LaunchFreshOp>, Vec<String>) {
plan_launch_fresh_calls_v2(clear_state, clear_keychain, app_path, false)
}
#[must_use]
pub fn plan_launch_fresh_calls_v2(
clear_state: bool,
clear_keychain: bool,
app_path: Option<&str>,
force_reinstall: bool,
) -> (Vec<LaunchFreshOp>, Vec<String>) {
let mut ops = vec![LaunchFreshOp::Terminate];
let mut warnings = Vec::new();
if clear_state {
if force_reinstall {
match app_path {
Some(path) => {
ops.push(LaunchFreshOp::Uninstall);
ops.push(LaunchFreshOp::Install(path.to_string()));
}
None => {
warnings.push(
"launch_fresh: force_reinstall=1 but app_path missing — cannot \
reinstall; falling back to in-place clear"
.to_string(),
);
ops.push(LaunchFreshOp::PrivacyResetAll);
ops.push(LaunchFreshOp::SandboxClearInPlace(String::new()));
}
}
} else {
ops.push(LaunchFreshOp::PrivacyResetAll);
ops.push(LaunchFreshOp::SandboxClearInPlace(String::new()));
if app_path.is_some() {
warnings.push(
"launch_fresh: app_path set but in-place clear used (v1.0.4 default); \
set SMIX_LAUNCH_FRESH_FORCE_REINSTALL=1 to fall back to \
uninstall+install for a bit-for-bit reinstall"
.to_string(),
);
}
}
}
if clear_keychain {
ops.push(LaunchFreshOp::KeychainReset);
}
ops.push(LaunchFreshOp::Launch);
(ops, warnings)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SessionState {
Healthy = 0,
Degraded = 1,
Cycling = 2,
Dead = 3,
}
impl SessionState {
pub fn from_u8(v: u8) -> Self {
match v {
1 => Self::Degraded,
2 => Self::Cycling,
3 => Self::Dead,
_ => Self::Healthy,
}
}
}
pub struct Session {
app: Option<App>,
session_id: String,
state: std::sync::Arc<std::sync::atomic::AtomicU8>,
}
impl Session {
pub fn app(&self) -> &App {
self.app.as_ref().expect("Session used after close()")
}
pub fn app_mut(&mut self) -> &mut App {
self.app.as_mut().expect("Session used after close()")
}
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn state(&self) -> SessionState {
SessionState::from_u8(
self.state.load(std::sync::atomic::Ordering::Acquire),
)
}
pub async fn still_valid(&self) -> Result<bool, ExpectationFailure> {
let app = self.app();
let runner = app.http_runner_client().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "session still_valid: driver has no HTTP runner client".into(),
..Default::default()
})
})?;
let resp = runner.list_sessions().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("session still_valid: {e}"),
..Default::default()
})
})?;
Ok(resp.sessions.iter().any(|s| s.session_id == self.session_id))
}
pub async fn reset_app_data(&self) -> Result<u64, ExpectationFailure> {
self.app().clear_app_data().await
}
pub async fn relaunch_app(&self) -> Result<u64, ExpectationFailure> {
let app = self.app();
let runner = app.http_runner_client().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "session relaunch_app: driver has no HTTP runner client".into(),
..Default::default()
})
})?;
let req = smix_runner_client::SessionRelaunchAppRequest {
session_id: self.session_id.clone(),
};
runner
.relaunch_session_app(&req)
.await
.map(|r| r.wall_ms)
.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("session relaunch_app: {e}"),
..Default::default()
})
})
}
pub async fn renew_activation(&self) -> Result<bool, ExpectationFailure> {
let app = self.app();
let runner = app.http_runner_client().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "session renew_activation: driver has no HTTP runner client".into(),
..Default::default()
})
})?;
let req = smix_runner_client::SessionRenewActivationRequest {
session_id: self.session_id.clone(),
};
runner
.renew_session_activation(&req)
.await
.map(|r| r.activated)
.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("session renew_activation: {e}"),
..Default::default()
})
})
}
pub async fn close(mut self) -> Result<App, ExpectationFailure> {
let mut app = self.app.take().expect("Session::close called twice");
if let Some(runner) = app.http_runner_client() {
let req = smix_runner_client::SessionCloseRequest {
session_id: self.session_id.clone(),
};
let _ = runner.close_session(&req).await;
}
app.driver.set_session_id(None);
Ok(app)
}
}
impl Drop for Session {
fn drop(&mut self) {
if let Some(app) = self.app.as_mut() {
app.driver.set_session_id(None);
}
}
}
pub struct App {
driver: Box<dyn smix_driver::Driver>,
device: Box<dyn DeviceControl>,
udid: Option<String>,
ledger: IssuedLedger,
}
impl App {
pub fn new(driver: SimctlDriver, simctl: SimctlClient) -> Self {
App {
driver: Box::new(driver),
device: Box::new(IosDeviceControl::with_client(simctl)),
udid: None,
ledger: IssuedLedger::new(),
}
}
pub fn new_with(driver: Box<dyn smix_driver::Driver>, device: Box<dyn DeviceControl>) -> Self {
App {
driver,
device,
udid: None,
ledger: IssuedLedger::new(),
}
}
pub fn http_runner_client(&self) -> Option<&HttpRunnerClient> {
self.driver.as_ios_driver().map(|ios| ios.runner())
}
pub async fn open_session(
mut self,
bundle_id: &str,
activate: bool,
) -> Result<Session, ExpectationFailure> {
let runner = self.http_runner_client().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "open_session: driver has no HTTP runner client".into(),
..Default::default()
})
})?;
let req = smix_runner_client::SessionOpenRequest {
bundle_id: bundle_id.to_string(),
activate,
};
let resp = runner.open_session(&req).await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("open_session wire: {e}"),
..Default::default()
})
})?;
let sid = resp.session_id.clone();
self.driver.set_session_id(Some(sid.clone()));
let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
SessionState::Healthy as u8,
));
if let Some(runner) = self.http_runner_client() {
runner.attach_session_state(state.clone());
}
Ok(Session {
app: Some(self),
session_id: sid,
state,
})
}
pub async fn connect_to_runner(port: u16) -> Result<Self, ExpectationFailure> {
let client = HttpRunnerClient::new(port);
client.ensure_reachable().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("runner unreachable: {e}"),
hint: Some(format!("check SmixRunner started on port {port}")),
..Default::default()
})
})?;
Ok(App {
driver: Box::new(SimctlDriver::new(client)),
device: Box::new(IosDeviceControl::new()),
udid: None,
ledger: IssuedLedger::new(),
})
}
pub async fn connect_to_runner_android(port: u16) -> Result<Self, ExpectationFailure> {
use smix_driver::AndroidDriver;
let client = HttpRunnerClient::new(port);
client.ensure_reachable().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("Android runner unreachable: {e}"),
hint: Some(format!(
"check smix-android-runner instrument is up on port {port}"
)),
..Default::default()
})
})?;
Ok(App {
driver: Box::new(AndroidDriver::new(client)),
device: Box::new(AndroidDeviceControl::new()),
udid: None,
ledger: IssuedLedger::new(),
})
}
pub fn with_udid<S: Into<String>>(mut self, udid: S) -> Self {
self.udid = Some(udid.into());
self
}
#[must_use]
pub fn with_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
let s: String = bundle.into();
self.driver.set_target_bundle_id(&s);
self
}
#[must_use]
pub fn with_auto_activate(mut self, activate: bool) -> Self {
self.driver.set_auto_activate(activate);
self
}
#[must_use]
pub fn with_force_key_events(mut self, force: bool) -> Self {
self.driver.set_force_key_events(force);
self
}
pub fn udid(&self) -> Option<&str> {
self.udid.as_deref()
}
pub fn driver(&self) -> &dyn smix_driver::Driver {
self.driver.as_ref()
}
pub fn simctl(&self) -> &SimctlClient {
self.device.as_ios_simctl().expect(
"App::simctl() called on non-iOS App; use app.device() for cross-platform access",
)
}
pub fn device(&self) -> &dyn DeviceControl {
self.device.as_ref()
}
fn require_udid(&self) -> Result<&str, ExpectationFailure> {
self.udid.as_deref().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "App not bound to a UDID; use .with_udid(...) first".into(),
..Default::default()
})
})
}
pub async fn clear_app_data(&self) -> Result<u64, ExpectationFailure> {
let start = std::time::Instant::now();
let runner = self.http_runner_client().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "clear_app_data: driver has no HTTP runner client".into(),
..Default::default()
})
})?;
let session_id = runner
.session_id()
.ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "clear_app_data: no session id on the client; \
run `smix run` (which auto-opens a session) or \
call `App::open_session` first"
.into(),
..Default::default()
})
})?
.to_string();
let bundle_id = runner
.target_bundle_id()
.ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "clear_app_data: no target bundle id on the client; \
pass --bundle-id to `smix run`"
.into(),
..Default::default()
})
})?
.to_string();
let udid = self.require_udid()?.to_string();
let req = smix_runner_client::SessionAppLifecycleRequest {
session_id: session_id.clone(),
};
runner.terminate_session_app(&req).await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("clear_app_data terminate: {e}"),
..Default::default()
})
})?;
self.device
.clear_app_sandbox(&udid, &bundle_id)
.await
.map_err(simctl_to_failure)?;
runner.launch_session_app(&req).await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("clear_app_data launch: {e}"),
..Default::default()
})
})?;
Ok(start.elapsed().as_millis() as u64)
}
pub async fn launch(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.launch(udid, bundle_id)
.await
.map(|_| ())
.map_err(simctl_to_failure)
}
pub async fn terminate(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.terminate(udid, bundle_id)
.await
.map_err(simctl_to_failure)
}
pub async fn install(&self, app_path: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.install(udid, app_path)
.await
.map_err(simctl_to_failure)
}
pub async fn uninstall(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.uninstall(udid, bundle_id)
.await
.map_err(simctl_to_failure)
}
pub async fn launch_fresh(
&self,
bundle_id: &str,
clear_state: bool,
clear_keychain: bool,
app_path: Option<&str>,
launch_arguments: &[String],
) -> Result<Vec<String>, ExpectationFailure> {
let udid = self.require_udid()?;
let force_reinstall = std::env::var("SMIX_LAUNCH_FRESH_FORCE_REINSTALL")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let (ops, warnings) = plan_launch_fresh_calls_v2(
clear_state,
clear_keychain,
app_path,
force_reinstall,
);
for op in &ops {
match op {
LaunchFreshOp::Terminate => {
let _ = self.device.terminate(udid, bundle_id).await;
}
LaunchFreshOp::Uninstall => {
self.device
.uninstall(udid, bundle_id)
.await
.map_err(simctl_to_failure)?;
}
LaunchFreshOp::Install(path) => {
self.device
.install(udid, path)
.await
.map_err(simctl_to_failure)?;
}
LaunchFreshOp::PrivacyResetAll => {
self.device
.privacy_reset_all(udid, bundle_id)
.await
.map_err(simctl_to_failure)?;
}
LaunchFreshOp::SandboxClearInPlace(_) => {
self.device
.clear_app_sandbox(udid, bundle_id)
.await
.map_err(simctl_to_failure)?;
}
LaunchFreshOp::KeychainReset => {
self.device
.keychain_reset(udid)
.await
.map_err(simctl_to_failure)?;
}
LaunchFreshOp::Launch => {
self.device
.launch_with_args(udid, bundle_id, launch_arguments)
.await
.map(|_| ())
.map_err(simctl_to_failure)?;
}
}
}
Ok(warnings)
}
pub async fn set_permission(
&self,
bundle_id: &str,
permission: SimctlPermission,
action: PermissionAction,
) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
let xperm = Permission::from_simctl(permission);
self.device
.set_permission(udid, bundle_id, xperm, action)
.await
.map_err(simctl_to_failure)
}
pub async fn launch_app_with_options(
&self,
opts: &LaunchAppOptions,
) -> Result<Vec<String>, ExpectationFailure> {
let udid = self.require_udid()?;
for (perm, action) in &opts.permissions {
self.set_permission(&opts.bundle_id, *perm, *action).await?;
}
let warnings = if opts.clear_state || opts.clear_keychain {
self.launch_fresh(
&opts.bundle_id,
opts.clear_state,
opts.clear_keychain,
opts.app_path.as_deref(),
&opts.arguments,
)
.await?
} else {
let _ = self.device.terminate(udid, &opts.bundle_id).await;
self.device
.launch_with_args(udid, &opts.bundle_id, &opts.arguments)
.await
.map(|_| ())
.map_err(simctl_to_failure)?;
Vec::new()
};
Ok(warnings)
}
pub async fn open_url(&self, url: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.open_url(udid, url)
.await
.map_err(simctl_to_failure)
}
pub async fn send_push(
&self,
bundle_id: &str,
apns_json_path: &str,
) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.send_push(udid, bundle_id, apns_json_path)
.await
.map_err(simctl_to_failure)
}
pub async fn screenshot(&self) -> Result<Vec<u8>, ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.screenshot(udid)
.await
.map_err(simctl_to_failure)
}
pub fn mark_fixture_action(&self, action_id: &str) {
self.ledger
.record_fixture_action(now_ms(), action_id.to_string());
}
pub async fn tree(&self) -> Result<A11yNode, ExpectationFailure> {
self.driver.tree(None).await
}
pub async fn describe(&self) -> Result<ScreenDescription, ExpectationFailure> {
let tree = self.driver.tree(None).await?;
Ok(ScreenDescription {
screenshot: None,
elements: collect_visible_summaries(&tree, smix_screen::DEFAULT_VISIBLE_LIMIT),
front_app: String::new(),
summary: String::new(),
captured_at: 0.0,
})
}
pub async fn find_one(
&self,
selector: &Selector,
) -> Result<Option<A11yNode>, ExpectationFailure> {
self.driver.find_one(selector, None).await
}
pub async fn find_all(&self, selector: &Selector) -> Result<Vec<A11yNode>, ExpectationFailure> {
self.driver.find_all(selector, None).await
}
pub async fn find(&self, selector: &Selector) -> Result<bool, ExpectationFailure> {
self.driver.find(selector, None).await
}
pub async fn system_popups(&self) -> Result<Vec<SystemPopup>, ExpectationFailure> {
self.driver.system_popups(None).await
}
pub async fn system_popup_action(
&self,
popup_id: &str,
button_id: &str,
) -> Result<bool, ExpectationFailure> {
self.ledger.record_tap(
now_ms(),
Some(format!(
"system_popup_action(popup={popup_id} btn={button_id})"
)),
);
self.driver.system_popup_action(popup_id, button_id).await
}
pub async fn tap(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
self.ledger
.record_tap(now_ms(), Some(format!("{selector:?}")));
self.driver.tap(selector, None).await
}
pub async fn tap_with_mode(
&self,
selector: &Selector,
mode: TapMode,
) -> Result<(), ExpectationFailure> {
self.ledger
.record_tap(now_ms(), Some(format!("{selector:?}")));
self.driver.tap_with_mode(selector, mode, None).await
}
pub async fn fill(&self, selector: &Selector, text: &str) -> Result<(), ExpectationFailure> {
self.ledger
.record_fill(now_ms(), Some(format!("{selector:?}")));
self.driver.fill(selector, text, None).await
}
pub async fn clear(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
self.driver.clear(selector, None).await
}
pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
self.driver.press_key(key).await
}
pub async fn scroll(
&self,
selector: &Selector,
direction: SwipeDirection,
) -> Result<(), ExpectationFailure> {
self.driver.scroll(selector, direction).await
}
pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
self.driver.swipe_once(direction).await
}
pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
self.driver.hide_keyboard().await
}
pub async fn go_back(&self) -> Result<(), ExpectationFailure> {
self.driver.back().await
}
pub async fn tap_at_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
self.ledger.record_tap_at_coord(now_ms(), nx, ny);
self.driver.tap_at_norm_coord(nx, ny).await
}
pub async fn tap_xcui(&self, id: &str) -> Result<(), ExpectationFailure> {
self.ledger
.record_tap(now_ms(), Some(format!("tap_xcui id={id}")));
self.driver.tap_by_id(id).await
}
pub async fn find_norm_coord(
&self,
selector: &Selector,
) -> Result<Option<(f64, f64)>, ExpectationFailure> {
self.driver.find_norm_coord(selector).await
}
pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
self.driver.webview_eval(js).await
}
pub async fn find_by_text_ocr(
&self,
text: &str,
locales: &[String],
) -> Result<Option<OcrFrame>, ExpectationFailure> {
let owned_default;
let locales_slice: &[String] = if locales.is_empty() {
owned_default = vec!["en".to_string()];
&owned_default
} else {
locales
};
self.driver
.find_text_by_ocr(text, locales_slice, "accurate")
.await
}
pub async fn tap_by_text_ocr(
&self,
text: &str,
locales: &[String],
) -> Result<(), ExpectationFailure> {
match self.find_by_text_ocr(text, locales).await? {
Some(frame) => {
self.ledger
.record_tap(now_ms(), Some(format!("tap_by_text_ocr text={text}")));
self.driver
.tap_at_norm_coord(frame.mid_x(), frame.mid_y())
.await
}
None => Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!("tap_by_text_ocr: OCR found no match for \"{text}\""),
hint: Some(
"Apple Vision OCR returned 0 matching observations; check spelling \
/ recognition language / surface contrast"
.into(),
),
..Default::default()
})),
}
}
pub async fn swipe_at_coord(
&self,
from: (f64, f64),
to: (f64, f64),
) -> Result<(), ExpectationFailure> {
self.ledger.record_swipe_at_coord(now_ms(), from, to);
self.driver.swipe_at_norm_coord(from, to).await
}
pub async fn scroll_screen(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
let (from, to) = match direction {
SwipeDirection::Down => ((0.5, 0.7), (0.5, 0.3)),
SwipeDirection::Up => ((0.5, 0.3), (0.5, 0.7)),
SwipeDirection::Left => ((0.7, 0.5), (0.3, 0.5)),
SwipeDirection::Right => ((0.3, 0.5), (0.7, 0.5)),
};
self.swipe_at_coord(from, to).await
}
pub async fn wait_for_not_visible(
&self,
selector: &Selector,
timeout: Duration,
) -> Result<(), ExpectationFailure> {
let start = std::time::Instant::now();
let poll_interval = Duration::from_millis(250);
loop {
if !self.find(selector).await? {
return Ok(());
}
if start.elapsed() >= timeout {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::AssertionFailed),
message: format!(
"wait_for_not_visible: element still visible after {}ms — {}",
timeout.as_millis(),
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
}));
}
tokio::time::sleep(poll_interval).await;
}
}
pub async fn assert_screenshot(
&self,
baseline_path: &std::path::Path,
max_hamming: u32,
) -> Result<AssertScreenshotOutcome, ExpectationFailure> {
let png = self.screenshot().await?;
let strict = std::env::var_os("SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD").is_some();
assert_screenshot_inner(&png, baseline_path, max_hamming, strict)
}
pub async fn assert_not_visible(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
if self.find(selector).await? {
Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::AssertionFailed),
message: format!(
"expect.toNotBeVisible: element is visible — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
}))
} else {
Ok(())
}
}
pub async fn set_clipboard(&self, text: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.pasteboard_set(udid, text)
.await
.map_err(simctl_to_failure)
}
pub async fn get_clipboard(&self) -> Result<String, ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.pasteboard_get(udid)
.await
.map_err(simctl_to_failure)
}
pub async fn paste_text(&self, text: Option<&str>) -> Result<(), ExpectationFailure> {
let to_type = match text {
Some(t) => {
self.set_clipboard(t).await?;
t.to_string()
}
None => self.get_clipboard().await?,
};
self.fill(&focused(), &to_type).await
}
pub async fn double_tap(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
self.ledger.record_tap(
now_ms(),
Some(format!("double:{}", describe_selector(selector))),
);
self.driver.double_tap(selector, None).await
}
pub async fn long_press(
&self,
selector: &Selector,
duration: Duration,
) -> Result<(), ExpectationFailure> {
self.ledger.record_tap(
now_ms(),
Some(format!(
"longpress({}ms):{}",
duration.as_millis(),
describe_selector(selector)
)),
);
self.driver.long_press(selector, duration, None).await
}
pub async fn set_location(
&self,
latitude: f64,
longitude: f64,
) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.location_set(udid, latitude, longitude)
.await
.map_err(simctl_to_failure)
}
pub async fn travel(
&self,
points: &[(f64, f64)],
speed_mps: Option<f64>,
) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.location_start(udid, points, speed_mps)
.await
.map_err(simctl_to_failure)
}
pub async fn add_media(&self, paths: &[String]) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.add_media(udid, paths)
.await
.map_err(simctl_to_failure)
}
pub async fn start_recording(&self, path: &str) -> Result<(), ExpectationFailure> {
let udid = self.require_udid()?;
self.device
.start_recording(udid, std::path::Path::new(path))
.await
.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("start_recording: {e}"),
suggestions: vec!["Call stop_recording before starting a new one".to_string()],
..Default::default()
})
})
}
pub async fn stop_recording(&self) -> Result<(), ExpectationFailure> {
self.device.stop_recording().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("stop_recording: {e}"),
suggestions: vec![
"Add a `- startRecording: <path>` step before this `stopRecording`".to_string(),
],
..Default::default()
})
})
}
pub async fn set_orientation(
&self,
orientation: MaestroOrientation,
) -> Result<(), ExpectationFailure> {
self.driver.set_orientation(orientation.to_driver()).await
}
pub async fn set_permissions(
&self,
bundle_id: &str,
permissions: &[(SimctlPermission, PermissionAction)],
) -> Result<(), ExpectationFailure> {
for (perm, action) in permissions {
self.set_permission(bundle_id, *perm, *action).await?;
}
Ok(())
}
pub async fn copy_text_from(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
let node = self.find_one(selector).await?.ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"copy_text_from: no element matched — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
})
})?;
let extracted = node
.value
.clone()
.or_else(|| node.text.clone())
.or_else(|| node.label.clone())
.unwrap_or_default();
if extracted.is_empty() {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::AssertionFailed),
message: format!(
"copy_text_from: matched element carries no extractable text \
(value/text/label all empty) — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
}));
}
self.set_clipboard(&extracted).await
}
pub async fn start_capsule_recording(&self) -> Result<(), ExpectationFailure> {
let ios = self.driver.as_ios_driver().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "start_capsule_recording: iOS-only API".into(),
..Default::default()
})
})?;
ios.runner().start_record().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("start_record failed: {e}"),
hint: Some(
"ensure the runner was started with TEST_RUNNER_SMIX_RECORD_ENABLED=1".into(),
),
..Default::default()
})
})?;
self.ledger.clear();
self.ledger.record_capsule_start(now_ms());
Ok(())
}
pub async fn stop_capsule_recording_and_reconcile(
&self,
window_ms: Option<u64>,
) -> Result<CapsuleReconciliation, ExpectationFailure> {
let ios = self.driver.as_ios_driver().ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: "stop_capsule_recording_and_reconcile: iOS-only API".into(),
..Default::default()
})
})?;
let events = ios.runner().stop_record().await.map_err(|e| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::DriverError),
message: format!("stop_record failed: {e}"),
..Default::default()
})
})?;
let issued = self.ledger.get_all();
Ok(reconcile(
&issued,
&events,
window_ms.unwrap_or(DEFAULT_RECONCILE_WINDOW_MS),
))
}
pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
self.driver.foreground(bundle_id).await
}
pub async fn wait_for(
&self,
selector: &Selector,
timeout: Duration,
) -> Result<A11yNode, ExpectationFailure> {
self.driver.wait_for(selector, timeout, None).await
}
pub async fn assert_visible(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
match self
.driver
.wait_for(selector, Duration::from_secs(5), None)
.await
{
Ok(_) => Ok(()),
Err(e) if e.code == FailureCode::Timeout => Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::NotVisible),
message: format!(
"expect.toBeVisible: not visible — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
visible_elements: e.visible_elements,
suggestions: e.suggestions,
..Default::default()
})),
Err(e) => Err(e),
}
}
pub async fn assert_enabled(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
let node = self.driver.find_one(selector, None).await?.ok_or_else(|| {
ExpectationFailure::new(FailureInit {
code: Some(FailureCode::ElementNotFound),
message: format!(
"expect.toBeEnabled: not found — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
})
})?;
if !node.enabled {
return Err(ExpectationFailure::new(FailureInit {
code: Some(FailureCode::NotEnabled),
message: format!(
"expect.toBeEnabled: disabled — {}",
describe_selector(selector)
),
selector: Some(selector.clone()),
..Default::default()
}));
}
Ok(())
}
pub async fn assert_text(&self, literal: &str) -> Result<(), ExpectationFailure> {
self.assert_visible(&text(literal)).await
}
}
fn simctl_to_failure(e: SimctlError) -> ExpectationFailure {
let (code, hint) = match &e {
SimctlError::Spawn(_) => (
FailureCode::DriverError,
Some("xcrun not found — install Xcode command-line tools".into()),
),
SimctlError::NonZeroExit { .. } => (FailureCode::DriverError, None),
SimctlError::Malformed { .. } => (FailureCode::DriverError, None),
SimctlError::Timeout { ms, .. } => (
FailureCode::Timeout,
Some(format!("subprocess timeout after {ms}ms")),
),
SimctlError::CaptureBackpressure { retry_after } => (
FailureCode::DriverError,
Some(format!(
"screenshot pacer circuit open — SimRenderServer under load; retry after {}ms",
retry_after.as_millis()
)),
),
_ => (FailureCode::DriverError, None),
};
ExpectationFailure::new(FailureInit {
code: Some(code),
message: format!("{e}"),
hint,
..Default::default()
})
}