Skip to main content

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}