smix_sdk/lib.rs
1//! smix-sdk — user-facing public surface for the smix Rust library.
2//!
3//! Wraps [`SimctlDriver`] + [`SimctlClient`] + [`HttpRunnerClient`] with
4//! an ergonomic Rust API.
5//!
6//! ```no_run
7//! use smix_sdk::{App, text};
8//! use std::time::Duration;
9//!
10//! # async fn demo() -> Result<(), smix_sdk::ExpectationFailure> {
11//! let app = App::connect_to_runner(22087).await?;
12//! app.launch("com.example.app").await?;
13//! app.wait_for(&text("Login"), Duration::from_secs(5)).await?;
14//! app.tap(&text("Login")).await?;
15//! app.fill(&text("Email"), "user@example.com").await?;
16//! app.press_key(smix_sdk::KeyName::Return).await?;
17//! # Ok(())
18//! # }
19//! ```
20
21#![doc(html_root_url = "https://docs.smix.dev/smix-sdk")]
22
23/// Visual regression perceptual hash (dhash 64-bit). Crate-internal:
24/// `compute_dhash` + `hamming_distance` back the public
25/// `App::assert_screenshot`, not part of the SDK surface.
26pub(crate) mod png_gray;
27pub(crate) mod screenshot_hash;
28
29/// Frame-to-frame stillness, backing `waitForAnimationToEnd`.
30pub mod quiescence;
31
32pub mod issued_ledger;
33pub use issued_ledger::{IssuedAction, IssuedKind, IssuedLedger};
34
35// DeviceControl trait + cross-platform Permission enum + iOS impl.
36// Two-trait architecture pair with smix-driver::Driver.
37pub mod device_control;
38pub mod ios_device;
39pub use device_control::{DeviceControl, Permission};
40pub use ios_device::IosDeviceControl;
41
42// Android DeviceControl impl backed by smix-adb.
43pub mod android_device;
44pub use android_device::{
45 ANDROID_ANIMATION_SCALES, AndroidDeviceControl, animation_settings_verified,
46 parse_resolved_activity,
47};
48
49pub mod capsule;
50pub use capsule::{
51 CapsuleReconciliation, DEFAULT_RECONCILE_WINDOW_MS, FOCUS_CHANGE_RAW_CODE, reconcile,
52};
53
54use std::time::Duration;
55
56/// Unix epoch in milliseconds — used by the issued-action ledger timestamps.
57fn now_ms() -> f64 {
58 chrono::Utc::now().timestamp_millis() as f64
59}
60
61/// Travel of one swipe gesture, in viewport-normalized units.
62const SWIPE_AMPLITUDE: f64 = 0.4;
63
64/// Endpoint of a one-gesture swipe starting at `start`.
65///
66/// `direction` is the navigation convention shared by the iOS and Kotlin
67/// runners (`Down` advances the content downward, so the finger travels
68/// upward), not the finger-travel convention. Clamped because an anchor
69/// near an edge would otherwise land the endpoint outside the viewport,
70/// which the runners reject.
71fn swipe_endpoint(start: (f64, f64), direction: SwipeDirection) -> (f64, f64) {
72 let (dx, dy) = match direction {
73 SwipeDirection::Down => (0.0, -SWIPE_AMPLITUDE),
74 SwipeDirection::Up => (0.0, SWIPE_AMPLITUDE),
75 SwipeDirection::Left => (-SWIPE_AMPLITUDE, 0.0),
76 SwipeDirection::Right => (SWIPE_AMPLITUDE, 0.0),
77 };
78 (
79 (start.0 + dx).clamp(0.0, 1.0),
80 (start.1 + dy).clamp(0.0, 1.0),
81 )
82}
83
84// -- re-exports for downstream user convenience ------------------------
85
86pub use smix_driver::{
87 ActOutcome, ActVerdict, AndroidDriver, HitElement, HttpRunnerClient, IncludeScope, OcrFrame,
88 RunnerScrollSelector, RunnerTransportError, SimctlDriver, SystemPopup, TapMode,
89};
90pub use smix_error::{
91 ExpectationFailure, FailureCode, FailureInit, build_suggestions, edit_distance, similarity,
92};
93pub use smix_input::{KeyName, SwipeDirection};
94pub use smix_screen::{
95 A11yNode, Bounds, ElementSummary, Rect, Role, ScreenDescription, collect_visible_summaries,
96 is_visible_enough, summarize_node, visible_area,
97};
98pub use smix_selector::{
99 AnchorBox, IndexModifiers, Modifiers, Pattern, Selector, True, describe_selector, match_text,
100 match_text_compiled,
101};
102pub use smix_simctl::surface_capture::CapturedFrame;
103pub use smix_simctl::{
104 Appearance, DeviceControlError, LaunchResult, SimctlClient, SimctlPermission,
105};
106
107/// Nucleus of `App::assert_screenshot`. Wraps fs IO + the dhash algorithm
108/// without any `App` dependency, so it can be exercised in host-side
109/// unit tests. Helper fn — not a user-facing capability.
110pub fn assert_screenshot_inner(
111 png_bytes: &[u8],
112 baseline_path: &std::path::Path,
113 max_hamming: u32,
114 strict: bool,
115) -> Result<AssertScreenshotOutcome, ExpectationFailure> {
116 use std::io::ErrorKind;
117 let baseline_bytes = match std::fs::read(baseline_path) {
118 Ok(b) => b,
119 Err(e) if e.kind() == ErrorKind::NotFound => {
120 if strict {
121 return Err(ExpectationFailure::new(FailureInit {
122 code: Some(FailureCode::DriverError),
123 message: format!(
124 "assert_screenshot: baseline missing at {} and SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD=1 set; record a baseline first",
125 baseline_path.display()
126 ),
127 suggestions: vec![
128 "Run once without SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD to auto-record"
129 .into(),
130 ],
131 ..Default::default()
132 }));
133 }
134 // auto-record: ensure parent dir exists + write current PNG.
135 if let Some(parent) = baseline_path.parent()
136 && !parent.as_os_str().is_empty()
137 {
138 std::fs::create_dir_all(parent).map_err(|e| {
139 ExpectationFailure::new(FailureInit {
140 code: Some(FailureCode::DriverError),
141 message: format!(
142 "assert_screenshot: failed to create baseline parent dir {}: {e}",
143 parent.display()
144 ),
145 ..Default::default()
146 })
147 })?;
148 }
149 std::fs::write(baseline_path, png_bytes).map_err(|e| {
150 ExpectationFailure::new(FailureInit {
151 code: Some(FailureCode::DriverError),
152 message: format!(
153 "assert_screenshot: failed to write baseline {}: {e}",
154 baseline_path.display()
155 ),
156 ..Default::default()
157 })
158 })?;
159 return Ok(AssertScreenshotOutcome::Recorded {
160 path: baseline_path.to_path_buf(),
161 });
162 }
163 Err(e) => {
164 return Err(ExpectationFailure::new(FailureInit {
165 code: Some(FailureCode::DriverError),
166 message: format!(
167 "assert_screenshot: failed to read baseline {}: {e}",
168 baseline_path.display()
169 ),
170 ..Default::default()
171 }));
172 }
173 };
174
175 let h_current = screenshot_hash::compute_dhash(png_bytes)?;
176 let h_baseline = screenshot_hash::compute_dhash(&baseline_bytes)?;
177 let hamming = screenshot_hash::hamming_distance(h_current, h_baseline);
178 if hamming <= max_hamming {
179 Ok(AssertScreenshotOutcome::Matched { hamming })
180 } else {
181 Err(ExpectationFailure::new(FailureInit {
182 code: Some(FailureCode::AssertionFailed),
183 message: format!(
184 "assertScreenshot: dhash hamming distance {hamming} exceeds threshold {max_hamming} (baseline {})",
185 baseline_path.display()
186 ),
187 suggestions: vec![
188 "Re-record baseline (delete the file) if the UI intentionally changed".into(),
189 "Or pin/wait for animations to settle before assertScreenshot".into(),
190 ],
191 ..Default::default()
192 }))
193 }
194}
195
196/// Outcome of [`App::assert_screenshot`]. Distinguishes the
197/// first-run "auto-record baseline" path (which writes the captured PNG to
198/// disk and treats as Ok) from the steady-state diff path (which compares
199/// dhash hamming distance against the recorded baseline).
200#[derive(Clone, Debug, PartialEq, Eq)]
201pub enum AssertScreenshotOutcome {
202 /// First run — baseline did not exist, captured PNG was written to
203 /// `path`. Subsequent runs will diff against this file.
204 Recorded {
205 /// Absolute path written.
206 path: std::path::PathBuf,
207 },
208 /// Baseline existed and matched within tolerance; `hamming` is the
209 /// observed dhash distance (≤ max_hamming).
210 Matched {
211 /// dhash hamming distance against baseline.
212 hamming: u32,
213 },
214}
215
216/// Maestro `setOrientation: <variant>` literal enum.
217/// `landscape` yaml alias normalizes to `LandscapeLeft` at the parser
218/// layer (same as maestro default). 1:1 mirrors `smix_driver::Orientation`.
219#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub enum MaestroOrientation {
222 /// Standard upright portrait.
223 Portrait,
224 /// Upside-down portrait.
225 PortraitUpsideDown,
226 /// Landscape with home indicator to the right (the default for
227 /// `landscape` alias).
228 LandscapeLeft,
229 /// Landscape with home indicator to the left.
230 LandscapeRight,
231}
232
233impl MaestroOrientation {
234 /// 1:1 forward into the driver-level [`smix_driver::Orientation`].
235 pub fn to_driver(self) -> smix_driver::Orientation {
236 match self {
237 Self::Portrait => smix_driver::Orientation::Portrait,
238 Self::PortraitUpsideDown => smix_driver::Orientation::PortraitUpsideDown,
239 Self::LandscapeLeft => smix_driver::Orientation::LandscapeLeft,
240 Self::LandscapeRight => smix_driver::Orientation::LandscapeRight,
241 }
242 }
243}
244
245/// Maestro yaml `permissions:` action — controls iOS privacy state per bundle.
246/// Maestro yaml parity:
247/// - `Grant` ↔ maestro yaml `"allow"` ↔ simctl `privacy grant`
248/// - `Revoke` ↔ maestro yaml `"deny"` ↔ simctl `privacy revoke`
249/// - `Reset` ↔ maestro yaml `"unset"` ↔ simctl `privacy reset`
250#[derive(Clone, Copy, Debug, PartialEq, Eq)]
251pub enum PermissionAction {
252 Grant,
253 Revoke,
254 Reset,
255}
256
257/// Typed shape of maestro yaml `launchApp:` mapping. Adapter assembles this
258/// from yaml fields; SDK consumes it in [`App::launch_app_with_options`].
259/// Covers maestro `launchApp.permissions / arguments / stopApp`.
260#[derive(Clone, Debug, PartialEq)]
261pub struct LaunchAppOptions {
262 pub bundle_id: String,
263 pub clear_state: bool,
264 pub clear_keychain: bool,
265 /// Process-level argv passed via `simctl launch -- <args>`.
266 pub arguments: Vec<String>,
267 /// Permission directives applied in declaration order BEFORE launch.
268 pub permissions: Vec<(SimctlPermission, PermissionAction)>,
269 /// App bundle path for clear_state / clear_keychain wipe — mirrors the
270 /// `launch_fresh::app_path` parameter; usually populated by the
271 /// adapter from `SMIX_APP_PATH_<NORMALIZED_BUNDLE>` env.
272 pub app_path: Option<String>,
273 /// Android launch activity override.
274 ///
275 /// `None` means "ask the device": the Android runner resolves the
276 /// package's launcher activity through the package manager. Set
277 /// only when an app has more than one entry point and the flow
278 /// wants a specific one. iOS ignores it — a bundle id already
279 /// names what to launch.
280 pub launch_activity: Option<String>,
281}
282
283/// Completion-signal wait strategy for the
284/// `resetAppData` verb (URL-scheme JS-wipe). The runtime executor
285/// (smix-adapter-maestro) is responsible for interpreting these
286/// variants; smix-sdk owns the type so the adapter's `Step` and the
287/// runtime's dispatch stay in agreement.
288#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub enum ResetAppDataWaitFor {
291 /// Regex against the external metro log. Requires the CLI has
292 /// been given `--metro-log <path>` so the runtime holds a
293 /// `smix_metro_log::MetroLogTail` subscribed to the file.
294 LogLinePattern(String),
295 /// Sleep the given milliseconds after firing the URL, then
296 /// return. Best-effort fallback for the no-metro-log case.
297 Sleep(u64),
298}
299
300// -------------------- selector helpers (ergonomic factories) --------
301
302/// `text("Login")` shortcut. Mirrors TS `{ text: 'Login' }` shorthand.
303#[must_use]
304pub fn text<S: Into<String>>(s: S) -> Selector {
305 Selector::Text {
306 text: Pattern::text(s),
307 modifiers: Modifiers::default(),
308 }
309}
310
311/// `text_regex("^Lo")` shortcut.
312#[must_use]
313pub fn text_regex<S: Into<String>>(p: S) -> Selector {
314 Selector::Text {
315 text: Pattern::regex(p),
316 modifiers: Modifiers::default(),
317 }
318}
319
320/// `id("btn-x")` shortcut.
321#[must_use]
322pub fn id<S: Into<String>>(s: S) -> Selector {
323 Selector::Id {
324 id: s.into(),
325 modifiers: Modifiers::default(),
326 }
327}
328
329/// `label("Settings")` shortcut.
330#[must_use]
331pub fn label<S: Into<String>>(s: S) -> Selector {
332 Selector::Label {
333 label: s.into(),
334 modifiers: Modifiers::default(),
335 }
336}
337
338/// `role(Role::Button)` shortcut.
339#[must_use]
340pub fn role(r: Role) -> Selector {
341 Selector::Role {
342 role: r,
343 name: None,
344 modifiers: Modifiers::default(),
345 }
346}
347
348/// `role_named(Role::Button, "Submit")` shortcut.
349#[must_use]
350pub fn role_named<S: Into<String>>(r: Role, name: S) -> Selector {
351 Selector::Role {
352 role: r,
353 name: Some(Pattern::text(name)),
354 modifiers: Modifiers::default(),
355 }
356}
357
358/// `focused()` shortcut.
359#[must_use]
360pub fn focused() -> Selector {
361 Selector::Focused {
362 focused: True(true),
363 }
364}
365
366/// Atomic op in the [`App::launch_fresh`] orchestration plan. Exposed
367/// so the plan is testable as a pure function (no `SimctlClient` stub
368/// — and a stub wouldn't help much since `SimctlClient` is a ZST).
369#[derive(Debug, Clone, PartialEq, Eq)]
370#[non_exhaustive]
371pub enum LaunchFreshOp {
372 /// `simctl terminate` on the target — clean SIGTERM, no crash-report
373 /// daemon interpretation.
374 Terminate,
375 /// `simctl uninstall` on the target. Used only when `SMIX_LAUNCH_FRESH_FORCE_REINSTALL=1` is set;
376 /// the default clear-state path uses [`SandboxClearInPlace`] to
377 /// avoid the iOS 26.5 XCUITest binding loss and
378 /// ReportCrash "<app> quit unexpectedly" dialog.
379 Uninstall,
380 /// `simctl install <path>` — reinstalls the .app bundle. Same
381 /// note as [`Uninstall`]: only used on the force-reinstall path.
382 Install(String),
383 /// `simctl privacy reset all` — wipes granted permissions
384 /// without touching the app's data. Companion to
385 /// [`SandboxClearInPlace`]; both make up the default in-place
386 /// clear-state path.
387 ///
388 /// Since smix 1.0.4.
389 PrivacyResetAll,
390 /// Wipe the app's sandbox
391 /// (`Documents/`, `Library/`, `tmp/`) via NSFileManager
392 /// on the running sim, without `simctl uninstall`. Preserves
393 /// the XCUITest binding and does not trip
394 /// ReportCrash. Argument is the target bundle-id.
395 SandboxClearInPlace(String),
396 KeychainReset,
397 Launch,
398}
399
400/// Pure planner for [`App::launch_fresh`] — computes the simctl op
401/// sequence + warnings from `(clear_state, clear_keychain, app_path)`.
402///
403/// Maestro `launchApp.clearState` semantic is "wipe app data without
404/// removing other apps", and iOS exposes no native per-app data wipe
405/// API. The closest aligned host-side path is `simctl uninstall`
406/// followed by `simctl install <app_path>`. So `clear_state=true`
407/// only triggers a real wipe when `app_path` is supplied (typically
408/// by the adapter reading `SMIX_APP_PATH_<BUNDLE_NORMALIZED>`);
409/// otherwise it gracefully falls back to the non-clear path
410/// (`terminate + launch`) with a warning.
411#[must_use]
412pub fn plan_launch_fresh_calls(
413 clear_state: bool,
414 clear_keychain: bool,
415 app_path: Option<&str>,
416) -> (Vec<LaunchFreshOp>, Vec<String>) {
417 plan_launch_fresh_calls_v2(clear_state, clear_keychain, app_path, false)
418}
419
420/// Extended planner with an explicit `force_reinstall`
421/// switch. When `false` (the new default), `clear_state=true` runs
422/// the in-place sandbox clear + privacy reset instead of
423/// `simctl uninstall + install`. This avoids the iOS 26.5 XCUITest
424/// binding loss and the ReportCrash "<app> quit
425/// unexpectedly" system dialog — both of which stem
426/// from the uninstall+install sequence.
427///
428/// When `force_reinstall=true` (opt-in via
429/// `SMIX_LAUNCH_FRESH_FORCE_REINSTALL=1`), the uninstall+install path
430/// is preserved for cases where a bit-for-bit reinstall is required.
431///
432/// Since smix 1.0.4.
433#[must_use]
434pub fn plan_launch_fresh_calls_v2(
435 clear_state: bool,
436 clear_keychain: bool,
437 app_path: Option<&str>,
438 force_reinstall: bool,
439) -> (Vec<LaunchFreshOp>, Vec<String>) {
440 // Terminate is always first — clean SIGTERM before any wipe.
441 let mut ops = vec![LaunchFreshOp::Terminate];
442 let mut warnings = Vec::new();
443 if clear_state {
444 if force_reinstall {
445 match app_path {
446 Some(path) => {
447 ops.push(LaunchFreshOp::Uninstall);
448 ops.push(LaunchFreshOp::Install(path.to_string()));
449 }
450 None => {
451 warnings.push(
452 "launch_fresh: force_reinstall=1 but app_path missing — cannot \
453 reinstall; falling back to in-place clear"
454 .to_string(),
455 );
456 ops.push(LaunchFreshOp::PrivacyResetAll);
457 ops.push(LaunchFreshOp::SandboxClearInPlace(String::new()));
458 }
459 }
460 } else {
461 // Default: in-place sandbox clear + privacy reset.
462 // No uninstall/install → XCUITest binding preserved
463 // + no ReportCrash dialog.
464 //
465 // The SandboxClearInPlace op receives an empty string
466 // here; the executor fills it in from the target bundle-id
467 // that App::launch_fresh already knows.
468 ops.push(LaunchFreshOp::PrivacyResetAll);
469 ops.push(LaunchFreshOp::SandboxClearInPlace(String::new()));
470 if app_path.is_some() {
471 warnings.push(
472 "launch_fresh: app_path set but in-place clear used (v1.0.4 default); \
473 set SMIX_LAUNCH_FRESH_FORCE_REINSTALL=1 to fall back to \
474 uninstall+install for a bit-for-bit reinstall"
475 .to_string(),
476 );
477 }
478 }
479 }
480 if clear_keychain {
481 ops.push(LaunchFreshOp::KeychainReset);
482 }
483 ops.push(LaunchFreshOp::Launch);
484 (ops, warnings)
485}
486
487// -------------------- App ------------------------------------------------
488
489/// Top-level surface for test authors. Mirrors Playwright's `page`
490/// deliberately — AI authoring quality is highest when names overlap
491/// with corpus the AI was trained on.
492///
493/// Every method is async — no chaining shortcuts, no fluent builder
494/// pattern. One step, one await, one observable side effect.
495///
496/// State of a runner-side session guard, exposed to consumers via
497/// [`Session::state`]. The runner's `X-Sim-Health` response header
498/// drives transitions.
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
500#[non_exhaustive]
501pub enum SessionState {
502 /// All observed signals inside envelope.
503 Healthy = 0,
504 /// At least one signal degraded (screenshot slow, /health stale).
505 Degraded = 1,
506 /// Runner is mid-cycle (supervisor auto-restart in progress).
507 Cycling = 2,
508 /// Runner or a watched subprocess (SimRenderServer / xcodebuild)
509 /// is gone.
510 Dead = 3,
511}
512
513impl SessionState {
514 /// Round-trip from the AtomicU8 storage; unknown wire values map
515 /// to `Healthy` (optimistic) so a new future state doesn't crash.
516 pub fn from_u8(v: u8) -> Self {
517 match v {
518 1 => Self::Degraded,
519 2 => Self::Cycling,
520 3 => Self::Dead,
521 _ => Self::Healthy,
522 }
523 }
524}
525
526/// [`App::open_session`]; drop the value or call [`Session::close`] to
527/// release. While a session is open the wrapped `App` sends the
528/// `Session-Id` header on every request, and the runner uses the
529/// session's cached `XCUIApplication` binding — no per-request
530/// activation storm.
531///
532/// The type is deliberately `!Clone` and takes ownership of the
533/// `App`. Consumer flow:
534///
535/// ```ignore
536/// use smix_sdk::App;
537/// # async fn demo() -> Result<(), smix_sdk::ExpectationFailure> {
538/// let mut app = App::connect_to_runner(22087).await?;
539/// let mut session = app.open_session("com.example.app", true).await?;
540/// session.app_mut().tap(&smix_sdk::text("Sign In")).await?;
541/// session.close().await?;
542/// # Ok(())
543/// # }
544/// ```
545///
546/// If `Session::close` is not called, `Drop` releases the reference
547/// but cannot await the network `POST /session/close` — a background
548/// task is spawned best-effort. Prefer `close()` explicitly.
549pub struct Session {
550 /// `Some` until `close()` moves the App out; `None` afterwards.
551 /// Every accessor asserts `Some` — using a Session after `close`
552 /// panics.
553 app: Option<App>,
554 session_id: String,
555 /// Sim-health state observed via `X-Sim-Health`
556 /// response header on the last runner response. Updated
557 /// automatically by [`HttpRunnerClient`] when the header is
558 /// present; defaults to `Healthy` at open time. Consumers read
559 /// via [`Session::state`].
560 state: std::sync::Arc<std::sync::atomic::AtomicU8>,
561}
562
563impl Session {
564 /// Immutable access to the underlying `App`. Every call goes out
565 /// with the `Session-Id` header. Panics if called after
566 /// [`Session::close`].
567 pub fn app(&self) -> &App {
568 self.app.as_ref().expect("Session used after close()")
569 }
570
571 /// Mutable access to the underlying `App`. Panics if called after
572 /// [`Session::close`].
573 pub fn app_mut(&mut self) -> &mut App {
574 self.app.as_mut().expect("Session used after close()")
575 }
576
577 /// The runner-issued session id. Opaque; useful only for logging.
578 pub fn session_id(&self) -> &str {
579 &self.session_id
580 }
581
582 /// Current sim-health classification observed via
583 /// the `X-Sim-Health` response header on the most recent runner
584 /// request. `Healthy` at open time (optimistic — the open call
585 /// itself succeeded); transitions to `Degraded` / `Cycling` / `Dead`
586 /// as the runner emits them.
587 pub fn state(&self) -> SessionState {
588 SessionState::from_u8(self.state.load(std::sync::atomic::Ordering::Acquire))
589 }
590
591 /// Probe the runner's `/session/list` and return
592 /// `true` iff this session's id is still known. Consumers wire
593 /// this after a `Session::state` transition to `Cycling` or `Dead`
594 /// to decide whether to keep using the session (still valid across
595 /// the cycle thanks to session persistence) or reopen a fresh one.
596 ///
597 /// Runner errors return `Err` — treat as "unknown"; consumers
598 /// typically bail on that path anyway.
599 pub async fn still_valid(&self) -> Result<bool, ExpectationFailure> {
600 let app = self.app();
601 let runner = app.http_runner_client().ok_or_else(|| {
602 ExpectationFailure::new(FailureInit {
603 code: Some(FailureCode::DriverError),
604 message: "session still_valid: driver has no HTTP runner client".into(),
605 ..Default::default()
606 })
607 })?;
608 let resp = runner.list_sessions().await.map_err(|e| {
609 ExpectationFailure::new(FailureInit {
610 code: Some(FailureCode::DriverError),
611 message: format!("session still_valid: {e}"),
612 ..Default::default()
613 })
614 })?;
615 Ok(resp
616 .sessions
617 .iter()
618 .any(|s| s.session_id == self.session_id))
619 }
620
621 /// Clear the session's target app data IN PLACE.
622 /// Runner-side does:
623 ///
624 /// 1. `XCUIApplication.terminate()` on the target (cooperative
625 /// termination via testmanagerd — does NOT signal
626 /// `com.apple.ReportCrash`).
627 /// 2. `FileManager` wipe of the app's sandbox subdirectories
628 /// (`Containers/Data/Application/<uuid>/{Documents, Library,
629 /// tmp}`) — install receipt untouched.
630 /// 3. `XCUIApplication.launch()` — re-attaches XCUITest binding
631 /// cleanly.
632 ///
633 /// This replaces the maestro `launchApp: { clearState: true }`
634 /// shape that triggered the "<app> quit unexpectedly" system dialog
635 /// on the iOS 26.5 sim even once `simctl uninstall + install` was
636 /// removed. The dialog is eliminated because the terminate path is
637 /// cooperative.
638 ///
639 /// Wraps [`App::clear_app_data`] with session-scoped ergonomics.
640 /// The heavy lifting (3-step orchestration + host-side wipe)
641 /// lives on `App::clear_app_data` because the UDID + bundle-id +
642 /// device + runner are all on `App`; this method is a thin
643 /// pass-through consumers can call when they hold a `Session`.
644 pub async fn reset_app_data(&self) -> Result<u64, ExpectationFailure> {
645 self.app().clear_app_data().await
646 }
647
648 /// Instruct the runner to `terminate()` + `launch()`
649 /// the session's cached `XCUIApplication` in place. Preserves the
650 /// session id and XCUITest binding. Consumers wire this after
651 /// observing an app crash (via [`Self::state`] transitioning to
652 /// `Degraded`/`Dead` with the runner itself Healthy) to auto-
653 /// recover without cycling the runner.
654 ///
655 /// Returns the wall-clock milliseconds the terminate + launch
656 /// cycle took, as reported by the runner.
657 pub async fn relaunch_app(&self) -> Result<u64, ExpectationFailure> {
658 let app = self.app();
659 let runner = app.http_runner_client().ok_or_else(|| {
660 ExpectationFailure::new(FailureInit {
661 code: Some(FailureCode::DriverError),
662 message: "session relaunch_app: driver has no HTTP runner client".into(),
663 ..Default::default()
664 })
665 })?;
666 let req = smix_runner_client::SessionRelaunchAppRequest {
667 session_id: self.session_id.clone(),
668 };
669 let resp = runner.relaunch_session_app(&req).await.map_err(|e| {
670 ExpectationFailure::new(FailureInit {
671 code: Some(FailureCode::DriverError),
672 message: format!("session relaunch_app: {e}"),
673 ..Default::default()
674 })
675 })?;
676 // `ok` went unread here too — a crash-guard fallback is 200 +
677 // ok:false and used to report the relaunch as done.
678 if !resp.ok {
679 return Err(ExpectationFailure::new(FailureInit {
680 code: Some(FailureCode::DriverError),
681 message: "session relaunch_app: runner reported ok:false — \
682 the app did not relaunch"
683 .into(),
684 ..Default::default()
685 }));
686 }
687 Ok(resp.wall_ms)
688 }
689
690 /// Ask the runner to re-issue `.activate()` on the session's
691 /// cached binding. Subject to the runner's per-session 2 s rate
692 /// limit; when rate-limited returns `Ok(false)`. When the runner
693 /// no longer has the session id in its table (evicted / restart)
694 /// returns an error.
695 pub async fn renew_activation(&self) -> Result<bool, ExpectationFailure> {
696 let app = self.app();
697 let runner = app.http_runner_client().ok_or_else(|| {
698 ExpectationFailure::new(FailureInit {
699 code: Some(FailureCode::DriverError),
700 message: "session renew_activation: driver has no HTTP runner client".into(),
701 ..Default::default()
702 })
703 })?;
704 let req = smix_runner_client::SessionRenewActivationRequest {
705 session_id: self.session_id.clone(),
706 };
707 runner
708 .renew_session_activation(&req)
709 .await
710 .map(|r| r.activated)
711 .map_err(|e| {
712 ExpectationFailure::new(FailureInit {
713 code: Some(FailureCode::DriverError),
714 message: format!("session renew_activation: {e}"),
715 ..Default::default()
716 })
717 })
718 }
719
720 /// Release the session — sends `POST /session/close` and clears
721 /// the `Session-Id` header from the client. Returns the wrapped
722 /// `App` so the caller can keep issuing requests via the legacy
723 /// per-request path.
724 pub async fn close(mut self) -> Result<App, ExpectationFailure> {
725 let mut app = self.app.take().expect("Session::close called twice");
726 // Best-effort: an error here means the runner is gone or the
727 // session id has already been evicted. Neither is fatal; the
728 // client-side header clear is the meaningful action.
729 if let Some(runner) = app.http_runner_client() {
730 let req = smix_runner_client::SessionCloseRequest {
731 session_id: self.session_id.clone(),
732 };
733 let _ = runner.close_session(&req).await;
734 }
735 app.driver.set_session_id(None);
736 Ok(app)
737 }
738}
739
740impl Drop for Session {
741 fn drop(&mut self) {
742 // Best-effort clear of the session header. Network
743 // `POST /session/close` is skipped — we can't `await` in Drop;
744 // prefer explicit `close()` when the release contract matters.
745 if let Some(app) = self.app.as_mut() {
746 app.driver.set_session_id(None);
747 }
748 }
749}
750
751pub struct App {
752 /// Sense+act trait stored as `Box<dyn>` for cross-platform
753 /// dispatch. iOS impl = `IosDriver`; Android impl = `AndroidDriver`.
754 driver: Box<dyn smix_driver::Driver>,
755 /// Sim/host control trait stored as `Box<dyn>` for cross-platform
756 /// dispatch. iOS impl = `IosDeviceControl`; Android impl =
757 /// `AndroidDeviceControl`.
758 device: Box<dyn DeviceControl>,
759 udid: Option<String>,
760 /// Capsule SDK issued-action ledger. Each `tap` / `tap_with_mode` /
761 /// `fill` / `tap_at_coord` records an entry before the driver call,
762 /// which is reconciled against EventRecorder 1018 focus-change events.
763 /// Capacity LRU 1024.
764 ledger: IssuedLedger,
765 /// v2 break #3: `assert_screenshot` strict-mode override, injected by
766 /// the CLI from `.smix/config.yaml switches.assertScreenshotNoAutorecord`.
767 /// `Some` wins; `None` (non-CLI callers) falls back to the
768 /// `SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD` env read.
769 assert_screenshot_strict: Option<bool>,
770 /// v2 break #3: `launch_fresh` force-reinstall override, injected by
771 /// the CLI from `.smix/config.yaml switches.launchFreshForceReinstall`.
772 /// `Some` wins; `None` falls back to the
773 /// `SMIX_LAUNCH_FRESH_FORCE_REINSTALL` env read.
774 launch_fresh_force_reinstall: Option<bool>,
775}
776
777/// How far past the requested hold to keep capturing.
778///
779/// Resolving the element runs before the touch goes down (measured at
780/// 320-450ms on an M-series simulator), so a window sized to the hold
781/// alone would stop capturing before the press began. Frames past
782/// lift-up come back labelled `Outside`, which costs one screenshot and
783/// tells the reader the hold was too short.
784const CAPTURE_OVERRUN_MS: u64 = 600;
785
786/// Cap on frames per press, so a long hold does not fill a directory.
787const MAX_PRESS_FRAMES: usize = 8;
788
789/// One frame taken during a press, with where it sits relative to it.
790#[derive(Clone, Debug)]
791pub struct PressFrame {
792 /// When the capture ran, on the host clock.
793 pub span: smix_driver::CaptureSpan,
794 /// Whether the touch was provably down for the whole capture.
795 pub placement: smix_driver::FramePlacement,
796 /// PNG bytes.
797 pub png: Vec<u8>,
798}
799
800/// A press and the frames taken alongside it.
801#[derive(Clone, Debug)]
802pub struct PressCapture {
803 /// When the touch was held, on the host clock.
804 pub timing: smix_driver::PressTiming,
805 /// Frames in capture order.
806 pub frames: Vec<PressFrame>,
807}
808
809impl PressCapture {
810 /// Frames the touch was provably down for.
811 pub fn during_press(&self) -> impl Iterator<Item = &PressFrame> {
812 self.frames
813 .iter()
814 .filter(|f| f.placement == smix_driver::FramePlacement::DuringPress)
815 }
816}
817
818impl App {
819 /// Construct from a fully-wired driver + simctl client. Use this when
820 /// you already manage Cell / UDID lifecycle externally.
821 ///
822 /// Back-compat constructor: still accepts `SimctlDriver` (alias to
823 /// `IosDriver`) and `SimctlClient`; internally wraps into
824 /// `Box<dyn Driver>` and `Box::new(IosDeviceControl::with_client(...))`.
825 pub fn new(driver: SimctlDriver, simctl: SimctlClient) -> Self {
826 App {
827 driver: Box::new(driver),
828 device: Box::new(IosDeviceControl::with_client(simctl)),
829 udid: None,
830 ledger: IssuedLedger::new(),
831 assert_screenshot_strict: None,
832 launch_fresh_force_reinstall: None,
833 }
834 }
835
836 /// Generic constructor for cross-platform tests. Use this when
837 /// constructing with non-iOS `Driver` / `DeviceControl` impls.
838 pub fn new_with(driver: Box<dyn smix_driver::Driver>, device: Box<dyn DeviceControl>) -> Self {
839 App {
840 driver,
841 device,
842 udid: None,
843 ledger: IssuedLedger::new(),
844 assert_screenshot_strict: None,
845 launch_fresh_force_reinstall: None,
846 }
847 }
848
849 /// Accessor for the underlying HTTP runner client, used by
850 /// [`Session`] to drive the `/session/*` routes. Returns `None`
851 /// when the driver is not backed by an HTTP runner (e.g. mock
852 /// driver in tests).
853 pub fn http_runner_client(&self) -> Option<&HttpRunnerClient> {
854 self.driver.as_ios_driver().map(|ios| ios.runner())
855 }
856
857 /// Driver access gated on the iOS session precondition (v2 break #1).
858 ///
859 /// iOS driving must go through a live runner session: without one the
860 /// request drops to the legacy per-request `resolveApp` + activate
861 /// path the session model exists to remove, so a missing session is a
862 /// named error, not a silent legacy fallback. Generalizes the
863 /// per-verb precedent that `clear_app_data_with_launch` already
864 /// carried onto every sense/act verb through this one chokepoint.
865 ///
866 /// iOS-only by construction: `http_runner_client()` is `Some` only for
867 /// the iOS driver. Android serves no `/session/*` route and has no
868 /// session concept — its `http_runner_client()` is `None`, so the
869 /// guard passes through and Android drives sessionless as before.
870 fn driving(&self) -> Result<&dyn smix_driver::Driver, ExpectationFailure> {
871 if let Some(runner) = self.http_runner_client()
872 && runner.session_id().is_none()
873 {
874 return Err(ExpectationFailure::new(FailureInit {
875 code: Some(FailureCode::DriverError),
876 message: "no session id on the client; run `smix run` (which \
877 auto-opens a session) or call `App::open_session` first"
878 .into(),
879 ..Default::default()
880 }));
881 }
882 Ok(self.driver.as_ref())
883 }
884
885 /// Open a runner-side session bound to `bundle_id`.
886 /// Subsequent requests via the returned [`Session`] send the
887 /// `Session-Id` header and skip per-request activation entirely.
888 ///
889 /// `activate = true` causes the runner to `.activate()` the target
890 /// once as part of the open (idiomatic when the target may not be
891 /// foregrounded yet). `activate = false` opens a passive binding
892 /// suitable when the caller has already ensured foreground state.
893 ///
894 /// This is the recommended surface for long-running gates. See the
895 /// [`Session`] docs for the lifecycle contract.
896 pub async fn open_session(
897 mut self,
898 bundle_id: &str,
899 activate: bool,
900 ) -> Result<Session, ExpectationFailure> {
901 let state = self.open_session_inner(bundle_id, activate).await?;
902 let session_id = self
903 .http_runner_client()
904 .and_then(|r| r.session_id())
905 .map(str::to_string)
906 .expect("open_session_inner set the session id on the client");
907 Ok(Session {
908 app: Some(self),
909 session_id,
910 state,
911 })
912 }
913
914 /// Open a runner session in place, keeping ownership of the `App`.
915 /// Same wire call + client mutation as [`Self::open_session`], but
916 /// does not consume `self` into a [`Session`]. For long-lived shared
917 /// `App` holders (the MCP server, the real-sim harness) that drive
918 /// through `&App` and so cannot move into a `Session`. After this the
919 /// iOS session guard on every driving verb is satisfied (v2 break #1).
920 pub async fn open_session_in_place(
921 &mut self,
922 bundle_id: &str,
923 activate: bool,
924 ) -> Result<(), ExpectationFailure> {
925 self.open_session_inner(bundle_id, activate)
926 .await
927 .map(|_| ())
928 }
929
930 async fn open_session_inner(
931 &mut self,
932 bundle_id: &str,
933 activate: bool,
934 ) -> Result<std::sync::Arc<std::sync::atomic::AtomicU8>, ExpectationFailure> {
935 let runner = self.http_runner_client().ok_or_else(|| {
936 ExpectationFailure::new(FailureInit {
937 code: Some(FailureCode::DriverError),
938 message: "open_session: driver has no HTTP runner client".into(),
939 ..Default::default()
940 })
941 })?;
942 let req = smix_runner_client::SessionOpenRequest {
943 bundle_id: bundle_id.to_string(),
944 activate,
945 };
946 let resp = runner.open_session(&req).await.map_err(|e| {
947 ExpectationFailure::new(FailureInit {
948 code: Some(FailureCode::DriverError),
949 message: format!("open_session wire: {e}"),
950 ..Default::default()
951 })
952 })?;
953 self.driver.set_session_id(Some(resp.session_id));
954 // Hook the state atomic into the client so future
955 // X-Sim-Health header transitions are visible to consumers via
956 // Session::state(). Optimistic Healthy at open time.
957 let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(
958 SessionState::Healthy as u8,
959 ));
960 if let Some(runner) = self.http_runner_client() {
961 runner.attach_session_state(state.clone());
962 }
963 Ok(state)
964 }
965
966 /// Convenience: connect to a runner on `127.0.0.1:{port}` and probe
967 /// `GET /health` once. Returns App ready for sense+act calls.
968 pub async fn connect_to_runner(port: u16) -> Result<Self, ExpectationFailure> {
969 let client = HttpRunnerClient::new(port);
970 client.ensure_reachable().await.map_err(|e| {
971 ExpectationFailure::new(FailureInit {
972 code: Some(FailureCode::DriverError),
973 message: format!("runner unreachable: {e}"),
974 hint: Some(format!("check SmixRunner started on port {port}")),
975 ..Default::default()
976 })
977 })?;
978 Ok(App {
979 driver: Box::new(SimctlDriver::new(client)),
980 device: Box::new(IosDeviceControl::new()),
981 udid: None,
982 ledger: IssuedLedger::new(),
983 assert_screenshot_strict: None,
984 launch_fresh_force_reinstall: None,
985 })
986 }
987
988 /// Like [`Self::connect_to_runner`] but WITHOUT the startup health
989 /// probe: construction always succeeds, and a runner that is not up
990 /// yet is reported by the first real call instead (every request
991 /// path already tells the unreachable story with an actionable
992 /// hint, and the client memoizes the probe).
993 ///
994 /// This exists for hosts whose own lifecycle starts before the
995 /// runner's — the MCP server is launched by the MCP client at
996 /// client startup, and dying there left a permanently dead server
997 /// in every session where `smix runner up` came second.
998 #[must_use]
999 pub fn connect_to_runner_lazy(port: u16) -> Self {
1000 App {
1001 driver: Box::new(SimctlDriver::new(HttpRunnerClient::new(port))),
1002 device: Box::new(IosDeviceControl::new()),
1003 udid: None,
1004 ledger: IssuedLedger::new(),
1005 assert_screenshot_strict: None,
1006 launch_fresh_force_reinstall: None,
1007 }
1008 }
1009
1010 /// Connect to an Android Kotlin runner on `127.0.0.1:{port}`
1011 /// (the host-forwarded port that proxies to the device-side
1012 /// runner instrumentation). Returns App ready for cross-platform
1013 /// sense+act calls dispatched via AndroidDriver + AndroidDeviceControl.
1014 pub async fn connect_to_runner_android(port: u16) -> Result<Self, ExpectationFailure> {
1015 use smix_driver::AndroidDriver;
1016 let client = HttpRunnerClient::new(port);
1017 client.ensure_reachable().await.map_err(|e| {
1018 ExpectationFailure::new(FailureInit {
1019 code: Some(FailureCode::DriverError),
1020 message: format!("Android runner unreachable: {e}"),
1021 hint: Some(format!(
1022 "check smix-android-runner instrument is up on port {port}"
1023 )),
1024 ..Default::default()
1025 })
1026 })?;
1027 Ok(App {
1028 driver: Box::new(AndroidDriver::new(client)),
1029 device: Box::new(AndroidDeviceControl::new()),
1030 udid: None,
1031 ledger: IssuedLedger::new(),
1032 assert_screenshot_strict: None,
1033 launch_fresh_force_reinstall: None,
1034 })
1035 }
1036
1037 /// Bind a UDID for lifecycle operations (launch/terminate/install/etc.).
1038 pub fn with_udid<S: Into<String>>(mut self, udid: S) -> Self {
1039 self.udid = Some(udid.into());
1040 self
1041 }
1042
1043 /// Thread the target bundle id down to the driver, which forwards
1044 /// it to the runner via the `App-Bundle-Id` HTTP header.
1045 ///
1046 /// The two platforms use it for different things. iOS rebinds
1047 /// `XCUIApplication(bundleIdentifier:)` per request, so calls stay
1048 /// pinned to the right app even when something else briefly claims
1049 /// foreground. Android has nothing to rebind — `/tree` walks every
1050 /// attached window — and uses the package to spell the
1051 /// `<pkg>:id/<tag>` resource ids that Compose emits on some
1052 /// layouts.
1053 #[must_use]
1054 pub fn with_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
1055 let s: String = bundle.into();
1056 self.driver.set_target_bundle_id(&s);
1057 self
1058 }
1059
1060 /// Enable auto-activate on every request. The iOS runner
1061 /// `.activate()`s the resolved target before operating. Costs one
1062 /// XCUITest activate call per request (~50-100ms); opt-in.
1063 ///
1064 /// **iOS only.** Android has no per-request activation to enable —
1065 /// foregrounding there is a `am start` shell command, which is what
1066 /// `open_session(.., activate: true)` and `foreground()` issue
1067 /// once, deliberately, rather than on every request.
1068 #[must_use]
1069 pub fn with_auto_activate(mut self, activate: bool) -> Self {
1070 self.driver.set_auto_activate(activate);
1071 self
1072 }
1073
1074 /// Force key-event dispatch mode on text-input verbs. Sends
1075 /// `Input-Dispatch-Mode: key-events` header on every request.
1076 /// Covers the RN hidden-input pattern where a11y-focus lookup
1077 /// returns nothing. Also opt-in via `smix run --force-key-events`.
1078 #[must_use]
1079 pub fn with_force_key_events(mut self, force: bool) -> Self {
1080 self.driver.set_force_key_events(force);
1081 self
1082 }
1083
1084 /// Inject the `assert_screenshot` strict-mode decision (v2 break #3).
1085 /// `Some(true)` = missing baseline is a `DriverError`; `Some(false)` =
1086 /// auto-record. `None` leaves the method on its
1087 /// `SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD` env fallback. `smix run`
1088 /// always injects `Some(resolved)` from `.smix/config.yaml`.
1089 #[must_use]
1090 pub fn with_assert_screenshot_strict(mut self, strict: Option<bool>) -> Self {
1091 self.assert_screenshot_strict = strict;
1092 self
1093 }
1094
1095 /// Inject the `launch_fresh` force-reinstall decision (v2 break #3).
1096 /// `Some(true)` = uninstall+install path; `Some(false)` = in-place
1097 /// clear. `None` leaves the method on its
1098 /// `SMIX_LAUNCH_FRESH_FORCE_REINSTALL` env fallback. `smix run` always
1099 /// injects `Some(resolved)` from `.smix/config.yaml`.
1100 #[must_use]
1101 pub fn with_launch_fresh_force_reinstall(mut self, force: Option<bool>) -> Self {
1102 self.launch_fresh_force_reinstall = force;
1103 self
1104 }
1105
1106 pub fn udid(&self) -> Option<&str> {
1107 self.udid.as_deref()
1108 }
1109
1110 /// Direct access to underlying `Driver` trait object.
1111 /// Use `app.driver()` for cross-platform calls; downcast to
1112 /// `IosDriver` only if iOS-specific behavior needed.
1113 pub fn driver(&self) -> &dyn smix_driver::Driver {
1114 self.driver.as_ref()
1115 }
1116
1117 /// Back-compat `&SimctlClient` accessor. **iOS-only.**
1118 /// Panics on Android (use `app.device()` instead).
1119 pub fn simctl(&self) -> &SimctlClient {
1120 self.device.as_ios_simctl().expect(
1121 "App::simctl() called on non-iOS App; use app.device() for cross-platform access",
1122 )
1123 }
1124
1125 /// Cross-platform sim/host control trait object.
1126 pub fn device(&self) -> &dyn DeviceControl {
1127 self.device.as_ref()
1128 }
1129
1130 fn require_udid(&self) -> Result<&str, ExpectationFailure> {
1131 self.udid.as_deref().ok_or_else(|| {
1132 ExpectationFailure::new(FailureInit {
1133 code: Some(FailureCode::DriverError),
1134 message: "App not bound to a UDID; use .with_udid(...) first".into(),
1135 ..Default::default()
1136 })
1137 })
1138 }
1139
1140 // ---- lifecycle (simctl-bound, requires UDID) ----------------------
1141
1142 /// Clear the current session's target app data
1143 /// IN PLACE via cooperative XCUIApplication.terminate() + host
1144 /// SimctlClient sandbox wipe + cooperative XCUIApplication.launch().
1145 /// Preserves XCUITest binding and does NOT signal
1146 /// `com.apple.ReportCrash` — the fix for the "<app> quit
1147 /// unexpectedly" system dialog that both `simctl uninstall + install`
1148 /// and `simctl terminate + simctl spawn rm` still tripped.
1149 ///
1150 /// Requires a Session-Id set on the driver (auto-populated by
1151 /// `smix run` and by `App::open_session`); errors otherwise.
1152 /// Requires an iOS driver + UDID (SimctlClient access for the
1153 /// host-side wipe). Android is not supported yet — Android's
1154 /// UiAutomator lifecycle differs and needs its own charter.
1155 pub async fn clear_app_data(&self) -> Result<u64, ExpectationFailure> {
1156 self.clear_app_data_with_launch(&[], &std::collections::BTreeMap::new())
1157 .await
1158 }
1159
1160 /// Same three-step orchestration as
1161 /// [`Self::clear_app_data`], but applies caller-supplied
1162 /// `launchArguments` and `launchEnvironment` to the runner's
1163 /// cooperative launch step. Unblocks scaffolding like the Expo
1164 /// SDK 57 dev-launcher server picker that `clearAppData` wipes
1165 /// from persisted state and can no longer restore via a URL
1166 /// Example yaml:
1167 ///
1168 /// ```yaml
1169 /// - clearAppData:
1170 /// launchArgs: ["-EXInternalMetroPort", "8081"]
1171 /// launchEnv:
1172 /// EX_DEV_CLIENT_METRO_URL: "http://localhost:8081"
1173 /// ```
1174 ///
1175 /// Empty args + empty env is equivalent to
1176 /// [`Self::clear_app_data`].
1177 pub async fn clear_app_data_with_launch(
1178 &self,
1179 launch_args: &[String],
1180 launch_env: &std::collections::BTreeMap<String, String>,
1181 ) -> Result<u64, ExpectationFailure> {
1182 let start = std::time::Instant::now();
1183 let runner = self.http_runner_client().ok_or_else(|| {
1184 ExpectationFailure::new(FailureInit {
1185 code: Some(FailureCode::DriverError),
1186 message: "clear_app_data: driver has no HTTP runner client".into(),
1187 ..Default::default()
1188 })
1189 })?;
1190 let session_id = runner
1191 .session_id()
1192 .ok_or_else(|| {
1193 ExpectationFailure::new(FailureInit {
1194 code: Some(FailureCode::DriverError),
1195 message: "clear_app_data: no session id on the client; \
1196 run `smix run` (which auto-opens a session) or \
1197 call `App::open_session` first"
1198 .into(),
1199 ..Default::default()
1200 })
1201 })?
1202 .to_string();
1203 let bundle_id = runner
1204 .target_bundle_id()
1205 .ok_or_else(|| {
1206 ExpectationFailure::new(FailureInit {
1207 code: Some(FailureCode::DriverError),
1208 message: "clear_app_data: no target bundle id on the client; \
1209 pass --bundle-id to `smix run`"
1210 .into(),
1211 ..Default::default()
1212 })
1213 })?
1214 .to_string();
1215 let udid = self.require_udid()?.to_string();
1216 // Launch step waits for `.runningForeground`
1217 // before returning by default. Prevents the caller's next
1218 // step (or a batch-retry firing another clearAppData) from
1219 // terminating the app mid-launch, which shows up as
1220 // `bug_type: 309 exec_terminated_before_ready` .ips writes.
1221 // The 15 s window is generous for cold RN/Expo
1222 // bundle load on iOS 26.5 sim.
1223 let terminate_req = smix_runner_client::SessionAppLifecycleRequest {
1224 session_id: session_id.clone(),
1225 ..Default::default()
1226 };
1227 let launch_req = smix_runner_client::SessionAppLifecycleRequest {
1228 session_id: session_id.clone(),
1229 args: launch_args.to_vec(),
1230 env: launch_env.clone(),
1231 wait_for_foreground_ms: Some(15_000),
1232 // Foreground is not the same as ready: a React Native or Expo
1233 // app is on screen well before its bundle has rendered anything
1234 // to touch, so wait for the interactive fingerprint as well.
1235 // Foreground lands in about 3 s on a cold load; first render can
1236 // take ten more.
1237 wait_for_interactive_ms: Some(30_000),
1238 };
1239 // Step 1 — cooperative terminate on runner side.
1240 let term = runner
1241 .terminate_session_app(&terminate_req)
1242 .await
1243 .map_err(|e| {
1244 ExpectationFailure::new(FailureInit {
1245 code: Some(FailureCode::DriverError),
1246 message: format!("clear_app_data terminate: {e}"),
1247 ..Default::default()
1248 })
1249 })?;
1250 // The response's `ok` went unread — a failed cooperative
1251 // terminate (crash fallback answers 200 + ok:false) proceeded
1252 // straight to the sandbox rm and reported success.
1253 if !term.ok {
1254 return Err(ExpectationFailure::new(FailureInit {
1255 code: Some(FailureCode::DriverError),
1256 message: "clear_app_data: the runner reported the cooperative \
1257 terminate did not happen (ok:false); refusing to wipe \
1258 the sandbox under a live app"
1259 .into(),
1260 ..Default::default()
1261 }));
1262 }
1263 // Step 2 — host-side sandbox wipe. Safe now — the target app
1264 // was cooperatively terminated via testmanagerd; no
1265 // ReportCrash signal fired. Uses `simctl spawn <UDID> /bin/rm`
1266 // via the SimctlClient inside the driver's DeviceControl impl.
1267 self.device
1268 .clear_app_sandbox(&udid, &bundle_id)
1269 .await
1270 .map_err(simctl_to_failure)?;
1271 // Step 3 — cooperative launch on runner side. Fresh app
1272 // instance sees the cleaned sandbox.
1273 let launched = runner.launch_session_app(&launch_req).await.map_err(|e| {
1274 ExpectationFailure::new(FailureInit {
1275 code: Some(FailureCode::DriverError),
1276 message: format!("clear_app_data launch: {e}"),
1277 ..Default::default()
1278 })
1279 })?;
1280 if !launched.ok {
1281 return Err(ExpectationFailure::new(FailureInit {
1282 code: Some(FailureCode::DriverError),
1283 message: "clear_app_data: the runner reported the relaunch did \
1284 not happen (ok:false) — the app is not running"
1285 .into(),
1286 ..Default::default()
1287 }));
1288 }
1289 Ok(start.elapsed().as_millis() as u64)
1290 }
1291
1292 pub async fn launch(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
1293 let udid = self.require_udid()?;
1294 self.device
1295 .launch(udid, bundle_id)
1296 .await
1297 .map(|_| ())
1298 .map_err(simctl_to_failure)
1299 }
1300
1301 pub async fn terminate(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
1302 let udid = self.require_udid()?;
1303 self.device
1304 .terminate(udid, bundle_id)
1305 .await
1306 .map_err(simctl_to_failure)
1307 }
1308
1309 pub async fn install(&self, app_path: &str) -> Result<(), ExpectationFailure> {
1310 let udid = self.require_udid()?;
1311 self.device
1312 .install(udid, app_path)
1313 .await
1314 .map_err(simctl_to_failure)
1315 }
1316
1317 pub async fn uninstall(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
1318 let udid = self.require_udid()?;
1319 self.device
1320 .uninstall(udid, bundle_id)
1321 .await
1322 .map_err(simctl_to_failure)
1323 }
1324
1325 /// Launch the app with optional state / keychain wipe before launch.
1326 /// See [`plan_launch_fresh_calls`] for the op sequence semantics.
1327 /// Returns the warnings produced by the planner (graceful fallback
1328 /// path is taken when `clear_state=true` but `app_path` is `None`).
1329 /// The caller should append these warnings to its own collector
1330 /// (e.g. `RunReport::warnings` in the maestro adapter).
1331 /// `launch_arguments` is process-level argv (`simctl launch --
1332 /// <args>`). Empty `&[]` skips argv injection.
1333 pub async fn launch_fresh(
1334 &self,
1335 bundle_id: &str,
1336 clear_state: bool,
1337 clear_keychain: bool,
1338 app_path: Option<&str>,
1339 launch_arguments: &[String],
1340 ) -> Result<Vec<String>, ExpectationFailure> {
1341 let udid = self.require_udid()?;
1342 // Force the uninstall+install path? CLI-injected config wins; a
1343 // non-CLI caller with no injection falls back to the
1344 // SMIX_LAUNCH_FRESH_FORCE_REINSTALL env probe. Default is the
1345 // in-place clear, which preserves the XCUITest binding and does
1346 // not trip ReportCrash.
1347 let force_reinstall = self.launch_fresh_force_reinstall.unwrap_or_else(|| {
1348 std::env::var("SMIX_LAUNCH_FRESH_FORCE_REINSTALL")
1349 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
1350 .unwrap_or(false)
1351 });
1352 let (ops, warnings) =
1353 plan_launch_fresh_calls_v2(clear_state, clear_keychain, app_path, force_reinstall);
1354 for op in &ops {
1355 match op {
1356 LaunchFreshOp::Terminate => {
1357 let _ = self.device.terminate(udid, bundle_id).await;
1358 }
1359 LaunchFreshOp::Uninstall => {
1360 self.device
1361 .uninstall(udid, bundle_id)
1362 .await
1363 .map_err(simctl_to_failure)?;
1364 }
1365 LaunchFreshOp::Install(path) => {
1366 self.device
1367 .install(udid, path)
1368 .await
1369 .map_err(simctl_to_failure)?;
1370 }
1371 LaunchFreshOp::PrivacyResetAll => {
1372 self.device
1373 .privacy_reset_all(udid, bundle_id)
1374 .await
1375 .map_err(simctl_to_failure)?;
1376 }
1377 LaunchFreshOp::SandboxClearInPlace(_) => {
1378 // Planner passes empty string; App knows the real
1379 // bundle-id already (function arg).
1380 self.device
1381 .clear_app_sandbox(udid, bundle_id)
1382 .await
1383 .map_err(simctl_to_failure)?;
1384 }
1385 LaunchFreshOp::KeychainReset => {
1386 self.device
1387 .keychain_reset(udid)
1388 .await
1389 .map_err(simctl_to_failure)?;
1390 }
1391 LaunchFreshOp::Launch => {
1392 self.device
1393 .launch_with_args(udid, bundle_id, launch_arguments, None)
1394 .await
1395 .map(|_| ())
1396 .map_err(simctl_to_failure)?;
1397 }
1398 }
1399 }
1400 Ok(warnings)
1401 }
1402
1403 /// Apply a permission action to a bundle.
1404 /// Maps maestro yaml `permissions: { camera: allow|deny|unset }` to simctl
1405 /// privacy. Sense+act live in core; the adapter only translates
1406 /// maestro yaml strings to the `PermissionAction` enum.
1407 pub async fn set_permission(
1408 &self,
1409 bundle_id: &str,
1410 permission: SimctlPermission,
1411 action: PermissionAction,
1412 ) -> Result<(), ExpectationFailure> {
1413 // Delegate to DeviceControl::set_permission with the cross-platform
1414 // Permission enum. Round-trip via Permission::from_simctl.
1415 let udid = self.require_udid()?;
1416 let xperm = Permission::from_simctl(permission);
1417 self.device
1418 .set_permission(udid, bundle_id, xperm, action)
1419 .await
1420 .map_err(simctl_to_failure)
1421 }
1422
1423 /// Typed launch entry: apply permissions in declaration order, then
1424 /// dispatch to [`Self::launch_fresh`] (when clear_state /
1425 /// clear_keychain) or `simctl terminate + launch_with_args`
1426 /// (otherwise). Maps maestro yaml `launchApp: { ... }` in full
1427 /// (permissions / arguments / clearState / clearKeychain). Returns
1428 /// warnings emitted by `launch_fresh` (caller appends to its own
1429 /// collector).
1430 pub async fn launch_app_with_options(
1431 &self,
1432 opts: &LaunchAppOptions,
1433 ) -> Result<Vec<String>, ExpectationFailure> {
1434 let udid = self.require_udid()?;
1435 for (perm, action) in &opts.permissions {
1436 self.set_permission(&opts.bundle_id, *perm, *action).await?;
1437 }
1438 let warnings = if opts.clear_state || opts.clear_keychain {
1439 self.launch_fresh(
1440 &opts.bundle_id,
1441 opts.clear_state,
1442 opts.clear_keychain,
1443 opts.app_path.as_deref(),
1444 &opts.arguments,
1445 )
1446 .await?
1447 } else {
1448 // stop+launch path: maestro `launchApp` defaults to
1449 // stopApp=true — terminate first, then launch_with_args.
1450 // terminate failure is tolerated (the app may already be
1451 // dead); launch must succeed.
1452 let _ = self.device.terminate(udid, &opts.bundle_id).await;
1453 self.device
1454 .launch_with_args(
1455 udid,
1456 &opts.bundle_id,
1457 &opts.arguments,
1458 opts.launch_activity.as_deref(),
1459 )
1460 .await
1461 .map(|_| ())
1462 .map_err(simctl_to_failure)?;
1463 Vec::new()
1464 };
1465 Ok(warnings)
1466 }
1467
1468 /// Ask the device to stop animating, and refuse if it did not.
1469 ///
1470 /// The strength differs by platform and the wording does not paper
1471 /// over it: Android zeroes three animation scales, which is off;
1472 /// iOS gets Reduce Motion, which is weaker but is as far as smix
1473 /// can push without the app under test cooperating. See
1474 /// `DeviceControl::set_animations_quiet`.
1475 pub async fn set_animations_quiet(&self, quiet: bool) -> Result<(), ExpectationFailure> {
1476 let udid = self.require_udid()?;
1477 self.device
1478 .set_animations_quiet(udid, quiet)
1479 .await
1480 .map_err(simctl_to_failure)
1481 }
1482
1483 /// Delete keys from the target app's persisted
1484 /// user-defaults store (iOS: NSUserDefaults via `simctl spawn
1485 /// defaults delete`; Android: unsupported, explicit error).
1486 /// Contract is "ensure keys absent" — already-absent keys are
1487 /// success. Terminate the app first: a running process caches its
1488 /// defaults in-memory and may rewrite keys at exit.
1489 ///
1490 /// Motivating case: expo-dev-launcher persists the most recent
1491 /// deep link and re-delivers it after every JS bundle load;
1492 /// deleting its storage key between
1493 /// terminate and relaunch neutralizes the replay at the source.
1494 pub async fn clear_user_defaults(
1495 &self,
1496 bundle_id: &str,
1497 keys: &[String],
1498 ) -> Result<(), ExpectationFailure> {
1499 let udid = self.require_udid()?;
1500 for key in keys {
1501 self.device
1502 .user_defaults_delete(udid, bundle_id, key)
1503 .await
1504 .map_err(simctl_to_failure)?;
1505 }
1506 Ok(())
1507 }
1508
1509 pub async fn open_url(&self, url: &str) -> Result<(), ExpectationFailure> {
1510 let udid = self.require_udid()?;
1511 self.device
1512 .open_url(udid, url)
1513 .await
1514 .map_err(simctl_to_failure)
1515 }
1516
1517 /// Deliver an APNS payload to `bundle_id` via `simctl push`.
1518 /// The payload file must contain a JSON dictionary with at least an
1519 /// `aps` key (per Apple's spec). Mirrors maestro yaml `sendPush:`
1520 /// once that command lands upstream — there is no public maestro
1521 /// yaml `sendPush` today, so this is SDK-only surface.
1522 pub async fn send_push(
1523 &self,
1524 bundle_id: &str,
1525 apns_json_path: &str,
1526 ) -> Result<(), ExpectationFailure> {
1527 let udid = self.require_udid()?;
1528 self.device
1529 .send_push(udid, bundle_id, apns_json_path)
1530 .await
1531 .map_err(simctl_to_failure)
1532 }
1533
1534 pub async fn screenshot(&self) -> Result<Vec<u8>, ExpectationFailure> {
1535 let udid = self.require_udid()?;
1536 self.device
1537 .screenshot(udid)
1538 .await
1539 .map_err(simctl_to_failure)
1540 }
1541
1542 /// Capture a frame preferring the fast raw-BGRA path (skips PNG
1543 /// encode+decode for diff-loop consumers). Falls back to a PNG frame when
1544 /// the direct IOSurface path is unavailable. See
1545 /// [`DeviceControl::capture_bgra`](crate::device_control::DeviceControl::capture_bgra).
1546 ///
1547 /// Since smix 2.0.0.
1548 pub async fn capture_bgra(
1549 &self,
1550 ) -> Result<smix_simctl::surface_capture::CapturedFrame, ExpectationFailure> {
1551 let udid = self.require_udid()?;
1552 self.device
1553 .capture_bgra(udid)
1554 .await
1555 .map_err(simctl_to_failure)
1556 }
1557
1558 /// Register a fixture-side action anchor in the SDK ledger so that
1559 /// the `capsule_reconcile` window can attribute the
1560 /// `kAXFirstResponderChangedNotification` (1018) the fixture's
1561 /// UIKit modal present is about to emit. Use this immediately
1562 /// before triggering a fixture-owned present path
1563 /// (UIActivityViewController, UIDocumentPickerViewController,
1564 /// SpringBoard system popup) that `smix-driver` would otherwise
1565 /// leave unattributed — without it, the phantom focus change
1566 /// inflates `unattributed_count`.
1567 pub fn mark_fixture_action(&self, action_id: &str) {
1568 self.ledger
1569 .record_fixture_action(now_ms(), action_id.to_string());
1570 }
1571
1572 // ---- sense (driver-bound) -----------------------------------------
1573
1574 pub async fn tree(&self) -> Result<A11yNode, ExpectationFailure> {
1575 self.driving()?.tree(None).await
1576 }
1577
1578 pub async fn describe(&self) -> Result<ScreenDescription, ExpectationFailure> {
1579 // describe() is App-layer aggregation (not on the Driver trait
1580 // per cross-platform design). Inlined: driver.tree() +
1581 // collect_visible_summaries.
1582 let tree = self.driving()?.tree(None).await?;
1583 Ok(ScreenDescription {
1584 screenshot: None,
1585 elements: collect_visible_summaries(&tree, smix_screen::DEFAULT_VISIBLE_LIMIT),
1586 // Same two sources as the driver's describe(). Two
1587 // constructors for one aggregate is how the fields drifted
1588 // to empty here while nobody noticed.
1589 front_app: smix_driver::front_app_of(&tree),
1590 summary: String::new(),
1591 captured_at: std::time::SystemTime::now()
1592 .duration_since(std::time::UNIX_EPOCH)
1593 .map(|d| d.as_millis() as f64)
1594 .unwrap_or(0.0),
1595 })
1596 }
1597
1598 pub async fn find_one(
1599 &self,
1600 selector: &Selector,
1601 ) -> Result<Option<A11yNode>, ExpectationFailure> {
1602 self.driving()?.find_one(selector, None).await
1603 }
1604
1605 pub async fn find_all(&self, selector: &Selector) -> Result<Vec<A11yNode>, ExpectationFailure> {
1606 self.driving()?.find_all(selector, None).await
1607 }
1608
1609 pub async fn find(&self, selector: &Selector) -> Result<bool, ExpectationFailure> {
1610 self.driving()?.find(selector, None).await
1611 }
1612
1613 pub async fn system_popups(&self) -> Result<Vec<SystemPopup>, ExpectationFailure> {
1614 self.driving()?.system_popups(None).await
1615 }
1616
1617 /// Tap a button on a previously enumerated system popup. `popup_id`
1618 /// and `button_id` round-trip from `system_popups()` — the runner
1619 /// walks the same scan order so callers don't need to manage an id
1620 /// map. Returns `Ok(true)` when matched and tapped, `Ok(false)` when
1621 /// the runner returned 404 not_found (popup or button id stale).
1622 /// Paired with `system_popups()` to close the sense/act loop on
1623 /// iOS system popups.
1624 pub async fn system_popup_action(
1625 &self,
1626 popup_id: &str,
1627 button_id: &str,
1628 ) -> Result<bool, ExpectationFailure> {
1629 // Anchor the popup-action tap in the SDK ledger so any
1630 // kAXFirstResponderChangedNotification 1018 the SpringBoard alert
1631 // dismissal emits attributes to this action (was previously
1632 // unattributed because system_popup_action skipped record_tap).
1633 self.ledger.record_tap(
1634 now_ms(),
1635 Some(format!(
1636 "system_popup_action(popup={popup_id} btn={button_id})"
1637 )),
1638 );
1639 self.driving()?
1640 .system_popup_action(popup_id, button_id)
1641 .await
1642 }
1643
1644 // ---- act (driver-bound) -------------------------------------------
1645
1646 /// Tap a selector, and report what the touch landed on.
1647 ///
1648 /// Returns an outcome rather than unit. "A touch was synthesised at
1649 /// that coordinate" and "the element was tapped" are different
1650 /// claims, and this made the first while callers read the second —
1651 /// a consumer watched it succeed ten times against a button whose
1652 /// counter never moved.
1653 ///
1654 /// The outcome carries what was aimed at, every named element
1655 /// containing the point afterwards, and a verdict. The elements
1656 /// come back even when the verdict is `Confirmed`, because the
1657 /// verdict cannot see occlusion and the list can.
1658 pub async fn tap(&self, selector: &Selector) -> Result<ActOutcome, ExpectationFailure> {
1659 self.ledger
1660 .record_tap(now_ms(), Some(format!("{selector:?}")));
1661 self.driving()?.tap(selector, None).await
1662 }
1663
1664 /// Tap a selector `times` times in a row.
1665 ///
1666 /// One resolve and one synthesise, with the touches spaced by
1667 /// `interval_ms` on the event timeline. `repeat` around `tapOn`
1668 /// sends each tap as its own request, which at ~400 ms per
1669 /// synthesise makes a rapid-tap gesture undriveable — and leaves
1670 /// the interval as whatever the round trip cost, so a flow cannot
1671 /// tell a slow harness from a broken app.
1672 ///
1673 /// `None` for either timing takes the runner's default.
1674 pub async fn tap_burst(
1675 &self,
1676 selector: &Selector,
1677 times: u32,
1678 interval_ms: Option<u32>,
1679 hold_ms: Option<u32>,
1680 ) -> Result<(), ExpectationFailure> {
1681 self.ledger
1682 .record_tap(now_ms(), Some(format!("{selector:?} x{times}")));
1683 self.driving()?
1684 .tap_burst(selector, times, interval_ms, hold_ms, None)
1685 .await
1686 }
1687
1688 /// Tap a selector via an explicit dispatch mode. Use
1689 /// `TapMode::DaemonProxySynthesize` for RN Pressable buttons that
1690 /// don't fire `onPress` with the default `tap()` Apple-native-event
1691 /// -chain dispatch. All other selectors should use `tap(selector)`
1692 /// (no mode) — the default host-resolve plus `tap_at_norm_coord`
1693 /// path is faster and works for non-RN-Pressable elements.
1694 pub async fn tap_with_mode(
1695 &self,
1696 selector: &Selector,
1697 mode: TapMode,
1698 ) -> Result<(), ExpectationFailure> {
1699 self.ledger
1700 .record_tap(now_ms(), Some(format!("{selector:?}")));
1701 self.driving()?.tap_with_mode(selector, mode, None).await
1702 }
1703
1704 pub async fn fill(&self, selector: &Selector, text: &str) -> Result<(), ExpectationFailure> {
1705 self.ledger
1706 .record_fill(now_ms(), Some(format!("{selector:?}")));
1707 self.driving()?.fill(selector, text, None).await
1708 }
1709
1710 pub async fn clear(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
1711 self.driving()?.clear(selector, None).await
1712 }
1713
1714 pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
1715 self.driving()?.press_key(key).await
1716 }
1717
1718 pub async fn scroll(
1719 &self,
1720 selector: &Selector,
1721 direction: SwipeDirection,
1722 ) -> Result<(), ExpectationFailure> {
1723 self.driving()?.scroll(selector, direction).await
1724 }
1725
1726 pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
1727 self.driving()?.swipe_once(direction).await
1728 }
1729
1730 pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
1731 self.driving()?.hide_keyboard().await
1732 }
1733
1734 pub async fn go_back(&self) -> Result<(), ExpectationFailure> {
1735 self.driving()?.back().await
1736 }
1737
1738 /// Tap at normalized (nx, ny) coordinates — escape hatch for
1739 /// coord-based maestro yaml port and other no-a11y-semantic
1740 /// scenarios. (nx, ny) MUST be in [0, 1] (normalized to viewport).
1741 ///
1742 /// **Escape hatch**: the Selector surface still forbids xpath/coord
1743 /// — this method is NOT a Selector, it is the direct
1744 /// Apple-native-event-chain wire entry. Only `tap` is exposed;
1745 /// `fill_at_coord` / `anchor_at_coord` are intentionally NOT
1746 /// provided.
1747 ///
1748 /// Prefer `tap(&selector)` for any path with a11y semantic. Use this
1749 /// only for yaml-port edge cases (e.g. maestro `point: "X%,Y%"`).
1750 pub async fn tap_at_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
1751 self.ledger.record_tap_at_coord(now_ms(), nx, ny);
1752 self.driving()?.tap_at_norm_coord(nx, ny).await
1753 }
1754
1755 /// Tap via `XCUIElement.tap()` over the XCTest gesture-recognizer
1756 /// chain instead of the default host-HID-at-coord path. The id
1757 /// selector is resolved runner-side via
1758 /// `XCUIApplication.descendants(matching: .any)
1759 /// .matching(identifier:).firstMatch.tap()`.
1760 ///
1761 /// **Why this exists**: SwiftUI `.sheet` / `.alert` /
1762 /// `.confirmationDialog` / `.fullScreenCover` dismiss buttons
1763 /// present in a separate modal window scene. The default
1764 /// `tap(&selector)` resolves the button frame and injects an IOKit
1765 /// event at that coord, but iOS routes the touch to the underlying
1766 /// scene's hit-target, so SwiftUI's onTap closure for the
1767 /// modal-window button never fires. `XCUIElement.tap()` operates on
1768 /// the resolved element handle and reaches the binding regardless
1769 /// of window topology.
1770 ///
1771 /// Use `tap(&selector)` for everything else — the default path is faster
1772 /// and works on non-modal SwiftUI / UIKit hierarchies.
1773 pub async fn tap_xcui(&self, id: &str) -> Result<(), ExpectationFailure> {
1774 self.ledger
1775 .record_tap(now_ms(), Some(format!("tap_xcui id={id}")));
1776 self.driving()?.tap_by_id(id).await
1777 }
1778
1779 /// Apple Vision OCR find. Returns the matching text observation's
1780 /// bounding box (UIKit normalized) or `None`. `locales` are BCP-47
1781 /// language subtags; empty defaults to the SDK's current locale
1782 /// (`["en"]` if unset). Covers "lib without testID but with
1783 /// visible text" scenarios.
1784 ///
1785 /// Find a selector's centroid as viewport-normalized `(nx, ny)`.
1786 /// Used by adapter AnchorRelative dispatch. Returns `None` when
1787 /// the selector resolves no node / empty frame.
1788 pub async fn find_norm_coord(
1789 &self,
1790 selector: &Selector,
1791 ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
1792 self.driving()?.find_norm_coord(selector).await
1793 }
1794
1795 /// Eval JS against the app-side WKWebView bridge. Returns the JS
1796 /// result as a JSON Value. Bridge must be running in the target app.
1797 pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
1798 self.driving()?.webview_eval(js).await
1799 }
1800
1801 pub async fn find_by_text_ocr(
1802 &self,
1803 text: &str,
1804 locales: &[String],
1805 ) -> Result<Option<OcrFrame>, ExpectationFailure> {
1806 let owned_default;
1807 let locales_slice: &[String] = if locales.is_empty() {
1808 owned_default = vec!["en".to_string()];
1809 &owned_default
1810 } else {
1811 locales
1812 };
1813 self.driving()?
1814 .find_text_by_ocr(text, locales_slice, "accurate")
1815 .await
1816 }
1817
1818 /// Find by OCR + tap at frame center via IOHID synthesize.
1819 /// Convenience for the common OCR fallback path (OCR keyword → tap).
1820 /// Returns `ElementNotFound` when OCR finds no match.
1821 pub async fn tap_by_text_ocr(
1822 &self,
1823 text: &str,
1824 locales: &[String],
1825 ) -> Result<(), ExpectationFailure> {
1826 match self.find_by_text_ocr(text, locales).await? {
1827 Some(frame) => {
1828 self.ledger
1829 .record_tap(now_ms(), Some(format!("tap_by_text_ocr text={text}")));
1830 self.driver
1831 .tap_at_norm_coord(frame.mid_x(), frame.mid_y())
1832 .await
1833 }
1834 None => Err(ExpectationFailure::new(FailureInit {
1835 code: Some(FailureCode::ElementNotFound),
1836 message: format!("tap_by_text_ocr: OCR found no match for \"{text}\""),
1837 hint: Some(
1838 "Apple Vision OCR returned 0 matching observations; check spelling \
1839 / recognition language / surface contrast"
1840 .into(),
1841 ),
1842 ..Default::default()
1843 })),
1844 }
1845 }
1846
1847 /// Swipe between two normalized coordinate points — escape hatch for
1848 /// coord-based maestro yaml port (`swipe: { from: "X%,Y%", to: "X%,Y%" }`).
1849 /// Both points MUST be in [0, 1].
1850 ///
1851 /// **Escape hatch**: companion to [`Self::tap_at_coord`]. The
1852 /// Selector surface still forbids xpath/coord — this method is NOT
1853 /// a Selector, it is the direct Apple-native-event-chain wire
1854 /// entry. Only the `tap` and `swipe` coord forms are exposed;
1855 /// `fill_at_coord` / `anchor_at_coord` / `hover_at_coord` are
1856 /// intentionally NOT provided.
1857 pub async fn swipe_at_coord(
1858 &self,
1859 from: (f64, f64),
1860 to: (f64, f64),
1861 ) -> Result<(), ExpectationFailure> {
1862 self.ledger.record_swipe_at_coord(now_ms(), from, to);
1863 self.driving()?.swipe_at_norm_coord(from, to).await
1864 }
1865
1866 /// Swipe one gesture anchored at an element: the gesture starts from
1867 /// the selector's resolved centroid instead of the viewport centre.
1868 ///
1869 /// Composite of [`Self::find_norm_coord`] + [`Self::swipe_at_coord`].
1870 /// [`Self::swipe_once`] and [`Self::scroll_screen`] always start at
1871 /// the viewport centre, so a caller holding an anchor element had no
1872 /// way to express "drag this row" — that gap is what this closes.
1873 ///
1874 /// `direction` follows the same navigation convention as
1875 /// [`Self::swipe_once`] / [`Self::scroll_screen`]: `Down` advances the
1876 /// content downward, so the finger travels upward.
1877 pub async fn swipe_from(
1878 &self,
1879 direction: SwipeDirection,
1880 from: &Selector,
1881 ) -> Result<(), ExpectationFailure> {
1882 let Some(start) = self.find_norm_coord(from).await? else {
1883 return Err(ExpectationFailure::new(FailureInit {
1884 code: Some(FailureCode::ElementNotFound),
1885 message: format!(
1886 "swipe_from({direction}): anchor selector resolved no element with a frame"
1887 ),
1888 selector: Some(from.clone()),
1889 hint: Some(
1890 "the anchor must be on screen and have a non-empty frame; \
1891 use swipe_once() for a viewport-centred swipe"
1892 .into(),
1893 ),
1894 ..Default::default()
1895 }));
1896 };
1897 self.swipe_at_coord(start, swipe_endpoint(start, direction))
1898 .await
1899 }
1900
1901 /// Viewport scroll one swipe in the given direction — no selector required.
1902 /// Maps to maestro yaml `scroll:` (bare, no args, defaults to down).
1903 ///
1904 /// Implementation: a single normalized-coord swipe from the viewport
1905 /// center to one edge (sense+act layer). [`Self::scroll`] is the
1906 /// scroll-until-visible composite and is orthogonal; `scroll_screen`
1907 /// is a pure act primitive.
1908 pub async fn scroll_screen(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
1909 let from = match direction {
1910 SwipeDirection::Down => (0.5, 0.7),
1911 SwipeDirection::Up => (0.5, 0.3),
1912 SwipeDirection::Left => (0.7, 0.5),
1913 SwipeDirection::Right => (0.3, 0.5),
1914 };
1915 self.swipe_at_coord(from, swipe_endpoint(from, direction))
1916 .await
1917 }
1918
1919 /// Assert that the selector is NOT visible. Dual of
1920 /// [`Self::assert_visible`]. Maps to maestro yaml `assertNotVisible:`.
1921 ///
1922 /// Assertion is a core sense+assertion primitive, not an
1923 /// adapter-only synthesis. Uses a single non-waiting
1924 /// [`Self::find`] probe; if the selector matches, raise
1925 /// `AssertionFailed`.
1926 /// Wait until the selector is NOT visible. Dual of [`Self::wait_for`].
1927 /// Polls [`Self::find`] at 250ms intervals; returns Ok the first instant
1928 /// the element is absent. Returns `AssertionFailed` if the element is
1929 /// still visible after `timeout` elapses.
1930 ///
1931 /// The assertion+sense composite is a core platform capability,
1932 /// not adapter-only synthesis. Maps to maestro yaml
1933 /// `extendedWaitUntil: { notVisible: ... , timeout: N }`.
1934 pub async fn wait_for_not_visible(
1935 &self,
1936 selector: &Selector,
1937 timeout: Duration,
1938 ) -> Result<(), ExpectationFailure> {
1939 let start = std::time::Instant::now();
1940 let poll_interval = Duration::from_millis(250);
1941 loop {
1942 if !self.find(selector).await? {
1943 return Ok(());
1944 }
1945 if start.elapsed() >= timeout {
1946 return Err(ExpectationFailure::new(FailureInit {
1947 code: Some(FailureCode::AssertionFailed),
1948 message: format!(
1949 "wait_for_not_visible: element still visible after {}ms — {}",
1950 timeout.as_millis(),
1951 describe_selector(selector)
1952 ),
1953 selector: Some(selector.clone()),
1954 ..Default::default()
1955 }));
1956 }
1957 tokio::time::sleep(poll_interval).await;
1958 }
1959 }
1960
1961 /// Assert the current sim screenshot matches a recorded baseline
1962 /// PNG via 64-bit dhash perceptual diff. Maestro
1963 /// `assertScreenshot: <baseline-path>`.
1964 ///
1965 /// **Baseline lifecycle** (same as maestro):
1966 /// - Baseline missing → write the captured PNG + return
1967 /// `Recorded { path }` (auto-record default).
1968 /// - `SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD=1` env → strict mode:
1969 /// missing baseline = `DriverError`.
1970 /// - Baseline present → dhash(baseline) vs dhash(current) → hamming
1971 /// distance; `≤ max_hamming` = `Matched { hamming }`, otherwise
1972 /// `AssertionFailed`.
1973 ///
1974 /// `max_hamming` typically ≤ 10 (adapter runtime arm pins 5).
1975 pub async fn assert_screenshot(
1976 &self,
1977 baseline_path: &std::path::Path,
1978 max_hamming: u32,
1979 ) -> Result<AssertScreenshotOutcome, ExpectationFailure> {
1980 let png = self.screenshot().await?;
1981 // CLI-injected config wins; a non-CLI caller with no injection
1982 // falls back to the SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD env
1983 // presence check.
1984 let strict = self
1985 .assert_screenshot_strict
1986 .unwrap_or_else(|| std::env::var_os("SMIX_ASSERT_SCREENSHOT_NO_AUTORECORD").is_some());
1987 assert_screenshot_inner(&png, baseline_path, max_hamming, strict)
1988 }
1989
1990 pub async fn assert_not_visible(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
1991 if self.find(selector).await? {
1992 Err(ExpectationFailure::new(FailureInit {
1993 code: Some(FailureCode::AssertionFailed),
1994 message: format!(
1995 "expect.toNotBeVisible: element is visible — {}",
1996 describe_selector(selector)
1997 ),
1998 selector: Some(selector.clone()),
1999 ..Default::default()
2000 }))
2001 } else {
2002 Ok(())
2003 }
2004 }
2005
2006 /// Write `text` to the iOS Simulator device pasteboard via
2007 /// `xcrun simctl pbcopy <udid>`. Maps to maestro yaml
2008 /// `setClipboard: "literal"`.
2009 ///
2010 /// Clipboard set is a core act primitive. Uses the simctl host-side
2011 /// path (device-scoped, explicit UDID) rather than the swift sim-side
2012 /// UIPasteboard wire — [`SimctlClient::pasteboard_set`] already has
2013 /// a stable wire, no need to add a new swift route.
2014 pub async fn set_clipboard(&self, text: &str) -> Result<(), ExpectationFailure> {
2015 let udid = self.require_udid()?;
2016 self.device
2017 .pasteboard_set(udid, text)
2018 .await
2019 .map_err(simctl_to_failure)
2020 }
2021
2022 /// Read the current iOS Simulator device pasteboard via
2023 /// `xcrun simctl pbpaste <udid>`. Returns the raw string (may be empty).
2024 pub async fn get_clipboard(&self) -> Result<String, ExpectationFailure> {
2025 let udid = self.require_udid()?;
2026 self.device
2027 .pasteboard_get(udid)
2028 .await
2029 .map_err(simctl_to_failure)
2030 }
2031
2032 /// Paste `text` into the currently-focused input field.
2033 /// Maps maestro yaml `pasteText: "literal"` (text-bearing form) and
2034 /// bare `- pasteText` (None form, reads current clipboard first).
2035 ///
2036 /// Both forms preserve the clipboard side-effect maestro yaml users
2037 /// implicitly rely on (literal form writes clipboard so the post-flow
2038 /// pasteboard mirrors the typed text — same as native "paste from
2039 /// clipboard" UX).
2040 pub async fn paste_text(&self, text: Option<&str>) -> Result<(), ExpectationFailure> {
2041 let to_type = match text {
2042 Some(t) => {
2043 self.set_clipboard(t).await?;
2044 t.to_string()
2045 }
2046 None => self.get_clipboard().await?,
2047 };
2048 self.fill(&focused(), &to_type).await
2049 }
2050
2051 /// Double-tap an element. Maps to maestro yaml
2052 /// `doubleTapOn: <selector>`. Backed by XCUIElement.doubleTap() on
2053 /// the swift sim side.
2054 pub async fn double_tap(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
2055 self.ledger.record_tap(
2056 now_ms(),
2057 Some(format!("double:{}", describe_selector(selector))),
2058 );
2059 self.driving()?.double_tap(selector, None).await
2060 }
2061
2062 /// Long-press an element for `duration`. Maps to maestro yaml
2063 /// `longPressOn` (optional `duration:` ms, default 500 on the
2064 /// adapter side). Backed by XCUIElement.press(forDuration:) on
2065 /// the swift sim side.
2066 pub async fn long_press(
2067 &self,
2068 selector: &Selector,
2069 duration: Duration,
2070 ) -> Result<(), ExpectationFailure> {
2071 self.ledger.record_tap(
2072 now_ms(),
2073 Some(format!(
2074 "longpress({}ms):{}",
2075 duration.as_millis(),
2076 describe_selector(selector)
2077 )),
2078 );
2079 // The driver reports when the touch was held; on its own that
2080 // is not something a caller can act on, so it is surfaced
2081 // where it becomes usable — paired with frames, in
2082 // `long_press_capturing`.
2083 self.driving()?
2084 .long_press(selector, duration, None)
2085 .await
2086 .map(|_timing| ())
2087 }
2088
2089 /// Long-press an element while capturing frames of the held state.
2090 ///
2091 /// The two halves run on different transports on purpose. The press
2092 /// goes to the runner, and every runner route that touches
2093 /// XCUITest queues behind an in-flight gesture — a `/tree` sent one
2094 /// second into a four-second press was measured returning only
2095 /// after the press ended. So the frames come from `simctl`, which
2096 /// does not go through XCUITest and answers while the runner is
2097 /// occupied.
2098 ///
2099 /// Each frame is judged against the interval the touch was
2100 /// provably down for, and frames that cannot be placed inside it
2101 /// say so. Handing back an unjudged frame is what this exists to
2102 /// stop: a consumer who screenshotted alongside a press got the
2103 /// resting screen and read it as "the animation never fired".
2104 pub async fn long_press_capturing(
2105 &self,
2106 selector: &Selector,
2107 duration: Duration,
2108 ) -> Result<PressCapture, ExpectationFailure> {
2109 let udid = self.require_udid()?.to_string();
2110 self.ledger.record_tap(
2111 now_ms(),
2112 Some(format!(
2113 "longpress-capturing({}ms):{}",
2114 duration.as_millis(),
2115 describe_selector(selector)
2116 )),
2117 );
2118 let driver = self.driving()?;
2119
2120 // Capture past the press rather than racing its completion: a
2121 // frame taken after lift-up is labelled and costs one
2122 // screenshot, whereas cancelling a capture mid-flight leaves a
2123 // `simctl` child behind.
2124 let deadline_ms =
2125 smix_driver::host_now_ms() + duration.as_millis() as u64 + CAPTURE_OVERRUN_MS;
2126 let capturing = async {
2127 let mut frames = Vec::new();
2128 while frames.len() < MAX_PRESS_FRAMES && smix_driver::host_now_ms() < deadline_ms {
2129 let start_ms = smix_driver::host_now_ms();
2130 match self.device.screenshot(&udid).await {
2131 Ok(png) => frames.push((
2132 smix_driver::CaptureSpan {
2133 start_ms,
2134 end_ms: smix_driver::host_now_ms(),
2135 },
2136 png,
2137 )),
2138 // One failing capture will fail identically for the
2139 // rest of the window; stop rather than burn it.
2140 Err(_) => break,
2141 }
2142 }
2143 frames
2144 };
2145
2146 let (pressed, captured) =
2147 tokio::join!(driver.long_press(selector, duration, None), capturing);
2148 let timing = pressed?;
2149 Ok(PressCapture {
2150 timing,
2151 frames: captured
2152 .into_iter()
2153 .map(|(span, png)| PressFrame {
2154 placement: smix_driver::press_frame_placement(&timing, &span),
2155 span,
2156 png,
2157 })
2158 .collect(),
2159 })
2160 }
2161
2162 /// Set sim location. Maestro `setLocation: { latitude, longitude }`.
2163 pub async fn set_location(
2164 &self,
2165 latitude: f64,
2166 longitude: f64,
2167 ) -> Result<(), ExpectationFailure> {
2168 let udid = self.require_udid()?;
2169 self.device
2170 .location_set(udid, latitude, longitude)
2171 .await
2172 .map_err(simctl_to_failure)
2173 }
2174
2175 /// Interpolate sim location along waypoints. Maestro `travel`.
2176 /// **Fire-and-return**: simctl injects scenario and returns immediately;
2177 /// sim continues interpolation in background. Caller must explicitly
2178 /// `waitForAnimationToEnd` / sleep if downstream logic depends on
2179 /// playback completion.
2180 pub async fn travel(
2181 &self,
2182 points: &[(f64, f64)],
2183 speed_mps: Option<f64>,
2184 ) -> Result<(), ExpectationFailure> {
2185 let udid = self.require_udid()?;
2186 self.device
2187 .location_start(udid, points, speed_mps)
2188 .await
2189 .map_err(simctl_to_failure)
2190 }
2191
2192 /// Add photos / videos / contacts to the sim library. Maestro
2193 /// `addMedia: <path>` (scalar) or `addMedia: [paths]` (array;
2194 /// adapter flattens to Vec).
2195 pub async fn add_media(&self, paths: &[String]) -> Result<(), ExpectationFailure> {
2196 let udid = self.require_udid()?;
2197 self.device
2198 .add_media(udid, paths)
2199 .await
2200 .map_err(simctl_to_failure)
2201 }
2202
2203 /// Start recording the sim display to `path`. Maestro
2204 /// `startRecording: <path>`. Spawns `xcrun simctl io recordVideo` as
2205 /// a long-running child; returns immediately. Errors if a recording
2206 /// is already in progress (call `stop_recording` first — no silent
2207 /// no-op).
2208 pub async fn start_recording(&self, path: &str) -> Result<(), ExpectationFailure> {
2209 // Recording state owned by IosDeviceControl (was on App).
2210 // Trait method returns Result<(), DeviceControlError>; double-start
2211 // surfaces as DeviceControlError::NonZeroExit (mapped here to
2212 // ExpectationFailure).
2213 let udid = self.require_udid()?;
2214 self.device
2215 .start_recording(udid, std::path::Path::new(path))
2216 .await
2217 .map_err(|e| {
2218 ExpectationFailure::new(FailureInit {
2219 code: Some(FailureCode::DriverError),
2220 message: format!("start_recording: {e}"),
2221 suggestions: vec!["Call stop_recording before starting a new one".to_string()],
2222 ..Default::default()
2223 })
2224 })
2225 }
2226
2227 /// Stop the active recording (SIGINT-and-wait simctl child; flushes
2228 /// mp4 trailer). Maestro `stopRecording`. Errors if no recording is
2229 /// active — explicit DriverError + hint, not a silent no-op.
2230 pub async fn stop_recording(&self) -> Result<(), ExpectationFailure> {
2231 // Delegate to DeviceControl::stop_recording.
2232 self.device.stop_recording().await.map_err(|e| {
2233 ExpectationFailure::new(FailureInit {
2234 code: Some(FailureCode::DriverError),
2235 message: format!("stop_recording: {e}"),
2236 suggestions: vec![
2237 "Add a `- startRecording: <path>` step before this `stopRecording`".to_string(),
2238 ],
2239 ..Default::default()
2240 })
2241 })
2242 }
2243
2244 /// Rotate sim. Maestro `setOrientation: portrait |
2245 /// portraitUpsideDown | landscapeLeft | landscapeRight`.
2246 /// Walks `driver.set_orientation` → POST /set-orientation → swift
2247 /// `XCUIDevice.shared.orientation`.
2248 pub async fn set_orientation(
2249 &self,
2250 orientation: MaestroOrientation,
2251 ) -> Result<(), ExpectationFailure> {
2252 self.driving()?
2253 .set_orientation(orientation.to_driver())
2254 .await
2255 }
2256
2257 /// Batch permission setter. Maestro `setPermissions: { camera:
2258 /// allow, location: deny, ... }` (top-level command, distinct from
2259 /// `launchApp.permissions`). Reuses `set_permission` per entry
2260 /// (sequential apply; fail-fast on first error).
2261 pub async fn set_permissions(
2262 &self,
2263 bundle_id: &str,
2264 permissions: &[(SimctlPermission, PermissionAction)],
2265 ) -> Result<(), ExpectationFailure> {
2266 for (perm, action) in permissions {
2267 self.set_permission(bundle_id, *perm, *action).await?;
2268 }
2269 Ok(())
2270 }
2271
2272 /// Read text content from the matched element and write it to the
2273 /// device pasteboard. Maps to maestro yaml `copyTextFrom:
2274 /// <selector>`. Field priority follows the maestro iOS driver:
2275 /// `value → text → label`. All three empty raises
2276 /// `AssertionFailed` — no silent no-op.
2277 pub async fn copy_text_from(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
2278 let node = self.find_one(selector).await?.ok_or_else(|| {
2279 ExpectationFailure::new(FailureInit {
2280 code: Some(FailureCode::ElementNotFound),
2281 message: format!(
2282 "copy_text_from: no element matched — {}",
2283 describe_selector(selector)
2284 ),
2285 selector: Some(selector.clone()),
2286 ..Default::default()
2287 })
2288 })?;
2289 let extracted = node
2290 .value
2291 .clone()
2292 .or_else(|| node.text.clone())
2293 .or_else(|| node.label.clone())
2294 .unwrap_or_default();
2295 if extracted.is_empty() {
2296 return Err(ExpectationFailure::new(FailureInit {
2297 code: Some(FailureCode::AssertionFailed),
2298 message: format!(
2299 "copy_text_from: matched element carries no extractable text \
2300 (value/text/label all empty) — {}",
2301 describe_selector(selector)
2302 ),
2303 selector: Some(selector.clone()),
2304 ..Default::default()
2305 }));
2306 }
2307 self.set_clipboard(&extracted).await
2308 }
2309
2310 // ---- Capsule helper ----------------------
2311
2312 /// Start Capsule recording — triggers the UITest runner
2313 /// `EventRecorder.installSwizzle` registration path (if
2314 /// `TEST_RUNNER_SMIX_RECORD_ENABLED=1` is set in the env) and
2315 /// clears the SDK-internal issued-action ledger. **The runner
2316 /// must be started with `TEST_RUNNER_SMIX_RECORD_ENABLED=1`**;
2317 /// otherwise swift-side `recordEnabled` is false, `installSwizzle`
2318 /// is skipped, and `/record/start` returns 404.
2319 pub async fn start_capsule_recording(&self) -> Result<(), ExpectationFailure> {
2320 // Capsule recording uses HttpRunnerClient::start_record which
2321 // is iOS-specific (XCUITest EventRecorder swizzle path).
2322 // Android impl will use a different mechanism.
2323 let ios = self.driver.as_ios_driver().ok_or_else(|| {
2324 ExpectationFailure::new(FailureInit {
2325 code: Some(FailureCode::DriverError),
2326 message: "start_capsule_recording: iOS-only API".into(),
2327 ..Default::default()
2328 })
2329 })?;
2330 ios.runner().start_record().await.map_err(|e| {
2331 ExpectationFailure::new(FailureInit {
2332 code: Some(FailureCode::DriverError),
2333 message: format!("start_record failed: {e}"),
2334 hint: Some(
2335 "ensure the runner was started with TEST_RUNNER_SMIX_RECORD_ENABLED=1".into(),
2336 ),
2337 ..Default::default()
2338 })
2339 })?;
2340 self.ledger.clear();
2341 // start_capsule_recording is itself an SDK-decision-layer act;
2342 // record it as an anchor so reconciliation attributes any
2343 // 1018 focus-change events during fixture lifecycle settle
2344 // (firstResponder reset after swizzle install, etc.) to this
2345 // action within the window, avoiding spurious unattributed
2346 // reports.
2347 self.ledger.record_capsule_start(now_ms());
2348 Ok(())
2349 }
2350
2351 /// Stop recording and reconcile. `window_ms = None` uses
2352 /// [`DEFAULT_RECONCILE_WINDOW_MS`] (500 ms). Returned
2353 /// [`CapsuleReconciliation`] includes the full
2354 /// `unattributed_events` detail.
2355 pub async fn stop_capsule_recording_and_reconcile(
2356 &self,
2357 window_ms: Option<u64>,
2358 ) -> Result<CapsuleReconciliation, ExpectationFailure> {
2359 // iOS-only via Driver::as_ios_driver downcast.
2360 let ios = self.driver.as_ios_driver().ok_or_else(|| {
2361 ExpectationFailure::new(FailureInit {
2362 code: Some(FailureCode::DriverError),
2363 message: "stop_capsule_recording_and_reconcile: iOS-only API".into(),
2364 ..Default::default()
2365 })
2366 })?;
2367 let events = ios.runner().stop_record().await.map_err(|e| {
2368 ExpectationFailure::new(FailureInit {
2369 code: Some(FailureCode::DriverError),
2370 message: format!("stop_record failed: {e}"),
2371 ..Default::default()
2372 })
2373 })?;
2374 let issued = self.ledger.get_all();
2375 Ok(reconcile(
2376 &issued,
2377 &events,
2378 window_ms.unwrap_or(DEFAULT_RECONCILE_WINDOW_MS),
2379 ))
2380 }
2381
2382 pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
2383 self.driving()?.foreground(bundle_id).await
2384 }
2385
2386 pub async fn wait_for(
2387 &self,
2388 selector: &Selector,
2389 timeout: Duration,
2390 ) -> Result<A11yNode, ExpectationFailure> {
2391 self.driving()?.wait_for(selector, timeout, None).await
2392 }
2393
2394 // ---- assertion matchers -------------------------------------------
2395
2396 /// Assert that the selector matches a visible element. Re-uses
2397 /// `wait_for` semantics (5s default budget) with `NotVisible`
2398 /// failure code.
2399 pub async fn assert_visible(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
2400 match self
2401 .driving()?
2402 .wait_for(selector, Duration::from_secs(5), None)
2403 .await
2404 {
2405 Ok(_) => Ok(()),
2406 Err(e) if e.code == FailureCode::Timeout => Err(ExpectationFailure::new(FailureInit {
2407 code: Some(FailureCode::NotVisible),
2408 message: format!(
2409 "expect.toBeVisible: not visible — {}",
2410 describe_selector(selector)
2411 ),
2412 selector: Some(selector.clone()),
2413 visible_elements: e.visible_elements,
2414 suggestions: e.suggestions,
2415 ..Default::default()
2416 })),
2417 Err(e) => Err(e),
2418 }
2419 }
2420
2421 /// Assert that the matched element has `enabled = true`.
2422 pub async fn assert_enabled(&self, selector: &Selector) -> Result<(), ExpectationFailure> {
2423 let node = self
2424 .driving()?
2425 .find_one(selector, None)
2426 .await?
2427 .ok_or_else(|| {
2428 ExpectationFailure::new(FailureInit {
2429 code: Some(FailureCode::ElementNotFound),
2430 message: format!(
2431 "expect.toBeEnabled: not found — {}",
2432 describe_selector(selector)
2433 ),
2434 selector: Some(selector.clone()),
2435 ..Default::default()
2436 })
2437 })?;
2438 if !node.enabled {
2439 return Err(ExpectationFailure::new(FailureInit {
2440 code: Some(FailureCode::NotEnabled),
2441 message: format!(
2442 "expect.toBeEnabled: disabled — {}",
2443 describe_selector(selector)
2444 ),
2445 selector: Some(selector.clone()),
2446 ..Default::default()
2447 }));
2448 }
2449 Ok(())
2450 }
2451
2452 /// Assert that the screen contains at least one element whose text /
2453 /// label / 6-field OR scan matches the literal. Useful for "page
2454 /// rendered" smoke checks without crafting a full selector tree.
2455 pub async fn assert_text(&self, literal: &str) -> Result<(), ExpectationFailure> {
2456 self.assert_visible(&text(literal)).await
2457 }
2458}
2459
2460// -------------------- error mapping -----------------------------------
2461
2462/// Map a device-control failure into the AI-readable failure shape.
2463/// Public because it IS the contract between the simctl layer's typed
2464/// errors and what a flow author sees.
2465pub fn simctl_to_failure(e: DeviceControlError) -> ExpectationFailure {
2466 let (code, hint) = match &e {
2467 DeviceControlError::AppNotInstalled { .. } => (
2468 FailureCode::AppNotRunning,
2469 Some(
2470 "install the app first: `smix sim install <device> \
2471 /path/to/YourApp.app` — or fix the flow's `appId:`"
2472 .into(),
2473 ),
2474 ),
2475 DeviceControlError::Spawn(_) => (
2476 FailureCode::DriverError,
2477 Some("xcrun not found — install Xcode command-line tools".into()),
2478 ),
2479 DeviceControlError::NonZeroExit { .. } => (FailureCode::DriverError, None),
2480 DeviceControlError::Malformed { .. } => (FailureCode::DriverError, None),
2481 DeviceControlError::Timeout { ms, .. } => (
2482 FailureCode::Timeout,
2483 Some(format!("subprocess timeout after {ms}ms")),
2484 ),
2485 DeviceControlError::CaptureBackpressure { retry_after } => (
2486 FailureCode::DriverError,
2487 Some(format!(
2488 "screenshot pacer circuit open — SimRenderServer under load; retry after {}ms",
2489 retry_after.as_millis()
2490 )),
2491 ),
2492 // DeviceControlError is #[non_exhaustive]; a new variant
2493 // arriving from a future patch falls through here as a generic
2494 // driver error until we augment this map.
2495 _ => (FailureCode::DriverError, None),
2496 };
2497 ExpectationFailure::new(FailureInit {
2498 code: Some(code),
2499 message: format!("{e}"),
2500 hint,
2501 ..Default::default()
2502 })
2503}