Skip to main content

smix_driver/
lib.rs

1//! smix-driver — decide layer (CLAUDE.md §12.1 middle).
2//!
3//! Wraps [`HttpRunnerClient`] (sense + act IPC) with host-side resolve
4//! dispatch. The default path is: SDK call → `driver.tap` → `tree()` →
5//! `resolve_selector()` → centroid → `runner.tap_at_norm_coord()`
6//! (Apple native event chain).
7//!
8//! # Failure model
9//!
10//! All methods return `Result<T, ExpectationFailure>` — AI-readable
11//! rendering lives in `smix-error`.
12//!
13//! # Implicit wait
14//!
15//! `tap` includes a 5s poll-and-retry loop: if the first
16//! `resolve_selector` returns None, sleep 250 ms then re-fetch tree +
17//! re-resolve, up to a 5s total budget. Once a candidate is found we
18//! tap **the same frame** to avoid a split-fetch race.
19
20#![doc(html_root_url = "https://docs.smix.dev/smix-driver")]
21
22use smix_error::{ExpectationFailure, FailureCode, FailureInit};
23use smix_host_coord_resolver::{HostResolveError, resolve_to_norm_coord};
24use smix_input::{KeyName, SwipeDirection};
25use smix_screen::{
26    A11yNode, DEFAULT_VISIBLE_LIMIT, ScreenDescription, collect_visible_summaries, summarize_node,
27};
28use smix_selector::{Modifiers, Pattern, Selector, True, describe_selector, match_text_compiled};
29use smix_selector_resolver::{
30    ResolverContext, resolve_selector, resolve_selector_all, resolve_selector_compiled,
31};
32use std::time::{Duration, Instant};
33use tokio::time::sleep;
34
35/// Sim device orientation. Driver-level type; SDK exposes a 1:1
36/// `MaestroOrientation` mirror for maestro yaml literal alignment.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum Orientation {
39    /// Standard upright portrait.
40    Portrait,
41    /// Upside-down portrait (home indicator at top).
42    PortraitUpsideDown,
43    /// Landscape with home indicator to the right.
44    LandscapeLeft,
45    /// Landscape with home indicator to the left.
46    LandscapeRight,
47}
48
49impl Orientation {
50    /// Wire literal sent to swift handler (`XCUIDevice.shared.orientation`
51    /// switch mapping).
52    pub fn as_wire(self) -> &'static str {
53        match self {
54            Self::Portrait => "portrait",
55            Self::PortraitUpsideDown => "portraitUpsideDown",
56            Self::LandscapeLeft => "landscapeLeft",
57            Self::LandscapeRight => "landscapeRight",
58        }
59    }
60}
61
62pub use smix_runner_client::{
63    HttpRunnerClient, IncludeScope, OcrFrame, RunnerScrollSelector, RunnerTransportError,
64    SystemPopup, TapMode,
65};
66
67const POLL_INTERVAL_MS: u64 = 250;
68const TOTAL_TIMEOUT_MS: u64 = 5000;
69const SCROLL_MAX_SWIPES: u32 = 30;
70
71/// Driver wrapping `HttpRunnerClient` with host-side resolve dispatch.
72///
73/// Called `IosDriver` to make the platform explicit in the
74/// cross-platform Driver trait architecture. The type alias
75/// `pub type SimctlDriver = IosDriver` (in `lib.rs`) keeps existing
76/// callers working without source edits.
77pub struct IosDriver {
78    runner: HttpRunnerClient,
79}
80
81impl IosDriver {
82    pub fn new(runner: HttpRunnerClient) -> Self {
83        IosDriver { runner }
84    }
85
86    pub fn runner(&self) -> &HttpRunnerClient {
87        &self.runner
88    }
89
90    /// Mutable accessor for the wrapped client. Used by the
91    /// Driver-trait pass-through (`set_target_bundle_id` /
92    /// `set_auto_activate`) so the client's per-request context can be
93    /// mutated after driver construction.
94    pub fn runner_mut(&mut self) -> &mut HttpRunnerClient {
95        &mut self.runner
96    }
97
98    /// Set the target bundle id sent to the runner on every request.
99    /// Threads `--bundle-id` from the CLI down to the wire so the
100    /// runner's per-request rebind logic can resolve to the right
101    /// XCUIApplication.
102    #[must_use]
103    pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
104        self.runner = self.runner.with_target_bundle_id(bundle);
105        self
106    }
107
108    /// Enable `App-Activate: true` on every request. Forces the runner
109    /// to call `.activate()` on the resolved target before each
110    /// operation. Wired from `smix run --activate`.
111    #[must_use]
112    pub fn with_auto_activate(mut self, activate: bool) -> Self {
113        self.runner = self.runner.with_auto_activate(activate);
114        self
115    }
116
117    // ---- sense ---------------------------------------------------------
118
119    /// Fetch full a11y tree (passthru to `GET /tree`).
120    pub async fn tree(
121        &self,
122        include: Option<IncludeScope>,
123    ) -> Result<A11yNode, ExpectationFailure> {
124        self.runner
125            .get_tree(include)
126            .await
127            .map_err(transport_to_failure)
128    }
129
130    /// Aggregate `ScreenDescription` (DFS visible summaries; no
131    /// screenshot here — caller adds when needed).
132    pub async fn describe(&self) -> Result<ScreenDescription, ExpectationFailure> {
133        let tree = self.tree(None).await?;
134        Ok(ScreenDescription {
135            screenshot: None,
136            elements: collect_visible_summaries(&tree, DEFAULT_VISIBLE_LIMIT),
137            front_app: String::new(),
138            summary: String::new(),
139            captured_at: 0.0,
140        })
141    }
142
143    /// Resolve selector → single node (passthru-ish: driver.tree + resolver).
144    pub async fn find_one(
145        &self,
146        selector: &Selector,
147        include: Option<IncludeScope>,
148    ) -> Result<Option<A11yNode>, ExpectationFailure> {
149        let tree = self.tree_with_retry(include).await?;
150        Ok(resolve_selector(&tree, selector).cloned())
151    }
152
153    /// Resolve selector → its centroid as viewport-normalized
154    /// `(nx, ny)` in `[0, 1]`. `None` when selector resolves no node OR
155    /// the matched node has an empty / offscreen frame. Used by adapter
156    /// AnchorRelative dispatch to find an anchor's center before adding
157    /// a `(dx, dy)` shift.
158    pub async fn find_norm_coord(
159        &self,
160        selector: &Selector,
161    ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
162        let tree = self.tree_with_retry(None).await?;
163        match resolve_to_norm_coord(&tree, selector) {
164            Ok((nx, ny)) => Ok(Some((nx, ny))),
165            Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
166            Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
167                code: Some(FailureCode::DriverError),
168                message: "find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)".into(),
169                ..Default::default()
170            })),
171            Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
172        }
173    }
174
175    /// Resolve selector → all matching nodes.
176    pub async fn find_all(
177        &self,
178        selector: &Selector,
179        include: Option<IncludeScope>,
180    ) -> Result<Vec<A11yNode>, ExpectationFailure> {
181        let tree = self.tree_with_retry(include).await?;
182        Ok(resolve_selector_all(&tree, selector)
183            .into_iter()
184            .cloned()
185            .collect())
186    }
187
188    /// Boolean existence quick-probe.
189    ///
190    /// Selector-type dispatch:
191    /// - Text selectors with no spatial / index modifiers → runner
192    ///   `/find` route (Apple element query, no full-tree snapshot —
193    ///   the fast path).
194    /// - Id / Label / Role / Focused / Anchor selectors, and Text
195    ///   with spatial (`below` / `near` / ...) or index (`nth` /
196    ///   `first` / `last`) modifiers → host-resolve fallback:
197    ///   `tree() + resolve_selector_all()` client-side. The runner
198    ///   `/find` route only knows how to query by text predicate, so
199    ///   anything richer has to ride on the full tree snapshot.
200    ///
201    /// Both dispatch branches poll within `TOTAL_TIMEOUT_MS` on
202    /// `/find` 500 or `/tree` 500 (sim still launching, runner
203    /// re-attach, etc.), matching the `wait_for` transient-retry
204    /// pattern.
205    pub async fn find(
206        &self,
207        selector: &Selector,
208        include: Option<IncludeScope>,
209    ) -> Result<bool, ExpectationFailure> {
210        if can_use_find_route(selector) {
211            let start = Instant::now();
212            let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
213            let mut last_transport_err: Option<ExpectationFailure> = None;
214            loop {
215                // v1.0.27 — the live route asks for on-screen (frame ∩
216                // app frame), not bare existence. Bare `.exists` is
217                // true for below-the-fold elements, which made `find`
218                // (and everything built on it: runFlow.when gates,
219                // wait_for_not_visible, tapOn poll probes) disagree
220                // with tapOn's honest resolution on scrollable screens.
221                match self.runner.find_on_screen(selector, include).await {
222                    Ok(present) => return Ok(present),
223                    Err(e) => {
224                        let failure = transport_to_failure(e);
225                        if start.elapsed() >= timeout {
226                            return Err(last_transport_err.unwrap_or(failure));
227                        }
228                        last_transport_err = Some(failure);
229                    }
230                }
231                sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
232            }
233        } else {
234            let tree = self.tree_with_retry(include).await?;
235            // v1.0.27 — tree-resolve branch gains the same live
236            // on-screen confirmation as wait_for. See
237            // `confirm_on_screen` for semantics.
238            let matched = resolve_selector_all(&tree, selector);
239            if matched.is_empty() {
240                return Ok(false);
241            }
242            Ok(self.confirm_on_screen(&matched, include).await)
243        }
244    }
245
246    /// v1.0.27 — live on-screen confirmation for tree-matched nodes.
247    ///
248    /// iOS 26.5 + RN 0.86 Fabric SNAPSHOT frames drift for
249    /// below-the-fold elements: the tree reports stale in-viewport
250    /// coords with `visible=true`, so the resolver's frame∩viewport
251    /// filter passes while the element is actually off screen. The
252    /// LIVE XCUI query re-resolves current layout and tells the
253    /// truth (the same reason `tapOn` fails honestly on such
254    /// elements).
255    ///
256    /// For up to the first 3 matched nodes that carry a
257    /// live-queryable handle (`identifier`, else `label` — the two
258    /// fields the runner `/find` predicate matches), ask the runner
259    /// whether an element with that handle is on screen right now.
260    /// Any confirmed node ⇒ true. Nodes with NO handle can't be
261    /// live-confirmed — if none of the matched nodes has a handle,
262    /// the tree verdict stands (pre-v1.0.27 semantics; OCR tiers
263    /// remain the fallback for handle-less degraded trees).
264    ///
265    /// Transport errors during confirmation also let the tree
266    /// verdict stand: a flaky live probe must not turn a legitimate
267    /// tree hit into a miss.
268    async fn confirm_on_screen(
269        &self,
270        matched: &[&A11yNode],
271        include: Option<IncludeScope>,
272    ) -> bool {
273        let mut had_handle = false;
274        for node in matched.iter().take(3) {
275            let handle = node
276                .identifier
277                .as_deref()
278                .filter(|s| !s.is_empty())
279                .or_else(|| node.label.as_deref().filter(|s| !s.is_empty()));
280            let Some(handle) = handle else { continue };
281            had_handle = true;
282            let probe = Selector::Text {
283                text: Pattern::Text(handle.to_string()),
284                modifiers: smix_selector::Modifiers::default(),
285            };
286            match self.runner.find_on_screen(&probe, include).await {
287                Ok(true) => return true,
288                Ok(false) => continue,
289                // Live probe unavailable → tree verdict stands.
290                Err(_) => return true,
291            }
292        }
293        // No node carried a live handle → cannot confirm → trust tree.
294        !had_handle
295    }
296
297    /// Transient `/tree` transport retry helper. Shared by
298    /// `find_one` / `find_all` / `find` (host-resolve branch). Mirrors
299    /// the transport-retry segment of `wait_for`: poll within
300    /// `TOTAL_TIMEOUT_MS` budget, sleep `POLL_INTERVAL_MS` between
301    /// attempts, surface only the last transport error on budget
302    /// exhaustion.
303    ///
304    /// Not folded into `wait_for` / `tap` because those loops have
305    /// additional in-loop logic (selector poll + ctx cache for
306    /// `wait_for`; `HostResolveError::NotFound` retry for `tap`) — DRY
307    /// here would harm clarity.
308    async fn tree_with_retry(
309        &self,
310        include: Option<IncludeScope>,
311    ) -> Result<A11yNode, ExpectationFailure> {
312        let start = Instant::now();
313        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
314        let mut last_transport_err: Option<ExpectationFailure> = None;
315        loop {
316            match self.tree(include).await {
317                Ok(tree) => return Ok(tree),
318                Err(e) => {
319                    if start.elapsed() >= timeout {
320                        return Err(last_transport_err.unwrap_or(e));
321                    }
322                    last_transport_err = Some(e);
323                }
324            }
325            sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
326        }
327    }
328
329    /// `GET /system-popups` passthru.
330    pub async fn system_popups(
331        &self,
332        include: Option<IncludeScope>,
333    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
334        self.runner
335            .system_popups(include)
336            .await
337            .map_err(transport_to_failure)
338    }
339
340    /// `POST /system-popup-action` passthru.
341    /// Returns `Ok(true)` when the runner matched and tapped, `Ok(false)`
342    /// on 404 not_found. Transport errors map to `ExpectationFailure`.
343    pub async fn system_popup_action(
344        &self,
345        popup_id: &str,
346        button_id: &str,
347    ) -> Result<bool, ExpectationFailure> {
348        self.runner
349            .system_popup_action(popup_id, button_id)
350            .await
351            .map_err(transport_to_failure)
352    }
353
354    // ---- act -----------------------------------------------------------
355
356    /// Tap a selector. Host-side resolve → centroid →
357    /// `runner.tap_at_norm_coord` (Apple native event chain), with a
358    /// 5s implicit wait + retry loop.
359    ///
360    /// The resolve+centroid+normalize pipeline lives in the
361    /// [`smix_host_coord_resolver`] stone; this method orchestrates the
362    /// smix-specific 5s implicit-wait loop plus AI-readable failure
363    /// rendering + runner injection.
364    pub async fn tap(
365        &self,
366        selector: &Selector,
367        include: Option<IncludeScope>,
368    ) -> Result<(), ExpectationFailure> {
369        let start = Instant::now();
370        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
371
372        let (nx, ny) = loop {
373            // Transport retry parity with wait_for / find. Tree fetch
374            // transient transport drops (runner socket refusal /
375            // concurrent-handling hiccup) are re-tried in-loop.
376            let tree = self.tree_with_retry(include).await?;
377            match resolve_to_norm_coord(&tree, selector) {
378                Ok(coord) => break coord,
379                Err(HostResolveError::NotFound) => {
380                    if start.elapsed() > timeout {
381                        let visible = collect_visible_summaries(&tree, 10);
382                        let target = base_text_or_id(selector);
383                        let suggestions =
384                            smix_error::build_suggestions(target.as_deref(), &visible);
385                        return Err(ExpectationFailure::new(FailureInit {
386                            code: Some(FailureCode::ElementNotFound),
387                            message: format!(
388                                "element not found: {}",
389                                describe_selector(selector)
390                            ),
391                            selector: Some(selector.clone()),
392                            visible_elements: visible,
393                            suggestions,
394                            hint: Some(
395                                "matched 0 nodes in the current a11y tree; check selector or wait for the screen to settle"
396                                    .into(),
397                            ),
398                            ..Default::default()
399                        }));
400                    }
401                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
402                    continue;
403                }
404                Err(HostResolveError::EmptyMatchedFrame) => {
405                    return Err(ExpectationFailure::new(FailureInit {
406                        code: Some(FailureCode::ElementNotFound),
407                        message: format!(
408                            "matched node has empty/offscreen frame: {}",
409                            describe_selector(selector)
410                        ),
411                        selector: Some(selector.clone()),
412                        hint: Some(
413                            "node bounds w*h == 0; element may be offscreen or hidden".into(),
414                        ),
415                        ..Default::default()
416                    }));
417                }
418                Err(HostResolveError::UnknownAppFrame) => {
419                    return Err(ExpectationFailure::new(FailureInit {
420                        code: Some(FailureCode::DriverError),
421                        message: format!(
422                            "tree bounds w/h ≤ 0 — unknown app frame: {}",
423                            describe_selector(selector)
424                        ),
425                        selector: Some(selector.clone()),
426                        hint: Some(
427                            "runner returned a tree with empty app frame; app may not be foregrounded"
428                                .into(),
429                        ),
430                        ..Default::default()
431                    }));
432                }
433                Err(HostResolveError::CentroidOutOfFrame { nx, ny }) => {
434                    return Err(ExpectationFailure::new(FailureInit {
435                        code: Some(FailureCode::ElementNotFound),
436                        message: format!(
437                            "matched node centroid out of app frame: {}",
438                            describe_selector(selector)
439                        ),
440                        selector: Some(selector.clone()),
441                        hint: Some(format!(
442                            "centroid (nx={:.3}, ny={:.3}) outside (0,1); element offscreen",
443                            nx, ny
444                        )),
445                        ..Default::default()
446                    }));
447                }
448            }
449        };
450
451        self.runner
452            .tap_at_norm_coord(nx, ny)
453            .await
454            .map_err(transport_to_failure)?;
455        Ok(())
456    }
457
458    /// Tap a selector via an explicit dispatch mode. Used to opt into
459    /// the runner-side `daemonProxySynthesize` path for RN Pressable
460    /// buttons whose `RCTTouchHandler` gesture recognizer does not fire
461    /// `onPress` when the host-side `tap_at_norm_coord` Apple native
462    /// event chain is used.
463    ///
464    /// `TapMode::DaemonProxySynthesize` routes through `POST /tap`
465    /// (runner resolves selector + synthesizes via
466    /// `XCTRunnerDaemonSession.daemonProxy
467    /// ._XCT_synthesizeEvent:completion:`). The existing
468    /// [`SimctlDriver::tap`] method keeps the host-resolve +
469    /// `tap_at_norm_coord` default for callers that don't need the
470    /// daemonProxy alternative.
471    pub async fn tap_with_mode(
472        &self,
473        selector: &Selector,
474        mode: TapMode,
475        include: Option<IncludeScope>,
476    ) -> Result<(), ExpectationFailure> {
477        let start = Instant::now();
478        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
479
480        loop {
481            match self.runner.tap(selector, mode, include).await {
482                Ok(_result) => return Ok(()),
483                Err(e) => {
484                    if start.elapsed() > timeout {
485                        return Err(transport_to_failure(e));
486                    }
487                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
488                    continue;
489                }
490            }
491        }
492    }
493
494    /// Double-tap a selector via swift sim-side XCUIElement.doubleTap().
495    /// 5s implicit-wait + retry on transport (mirrors
496    /// [`Self::tap_with_mode`]). Same as Maestro `doubleTapOn`.
497    pub async fn double_tap(
498        &self,
499        selector: &Selector,
500        include: Option<IncludeScope>,
501    ) -> Result<(), ExpectationFailure> {
502        let start = Instant::now();
503        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
504        loop {
505            match self.runner.double_tap(selector, include).await {
506                Ok(_result) => return Ok(()),
507                Err(e) => {
508                    if start.elapsed() > timeout {
509                        return Err(transport_to_failure(e));
510                    }
511                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
512                    continue;
513                }
514            }
515        }
516    }
517
518    /// Long-press a selector for `duration` via swift sim-side
519    /// XCUIElement.press(forDuration:). 5s implicit-wait + retry on
520    /// transport. Same as Maestro `longPressOn`.
521    pub async fn long_press(
522        &self,
523        selector: &Selector,
524        duration: Duration,
525        include: Option<IncludeScope>,
526    ) -> Result<(), ExpectationFailure> {
527        let start = Instant::now();
528        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
529        let duration_ms = duration.as_millis().min(u64::MAX as u128) as u64;
530        loop {
531            match self.runner.long_press(selector, duration_ms, include).await {
532                Ok(_result) => return Ok(()),
533                Err(e) => {
534                    if start.elapsed() > timeout {
535                        return Err(transport_to_failure(e));
536                    }
537                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
538                    continue;
539                }
540            }
541        }
542    }
543
544    /// Rotate sim via swift `XCUIDevice.shared.orientation`. Routes to
545    /// the runner-client `set_orientation` → POST /set-orientation.
546    pub async fn set_orientation(
547        &self,
548        orientation: Orientation,
549    ) -> Result<(), ExpectationFailure> {
550        self.runner
551            .set_orientation(orientation.as_wire())
552            .await
553            .map_err(transport_to_failure)?;
554        Ok(())
555    }
556
557    /// Fill text into the focused / matched input.
558    ///
559    /// **chunked-fill**: the swift runner's daemon `_XCT_sendString`
560    /// path bursts every keystroke at up to 200 chars/sec, which on
561    /// real-sim outraces React Native's `onChangeText` debounce on
562    /// the main thread — only the leading 2-3 keystrokes commit
563    /// before later ones are dropped by the JS thread reconciler.
564    /// The fix is host-side: split `text` into 1-char chunks and post
565    /// each to runner `/fill` separately, with an `INTER_CHAR_PAUSE_MS`
566    /// gap so the JS thread can flush its onChangeText callback before
567    /// the next keystroke fires.
568    ///
569    /// **selector-type dispatch** (mirrors `find()`): runner `/fill`
570    /// only accepts text selectors (`selector.text` field or the
571    /// `_focused_` magic). For Id / Label / Role / Anchor /
572    /// Text-with-modifiers selectors, host-resolve + tap the target
573    /// first to give it keyboard focus, then fill via the `_focused_`
574    /// magic. Text-without-modifiers selectors take the fast path
575    /// (direct chunked fill).
576    pub async fn fill(
577        &self,
578        selector: &Selector,
579        text: &str,
580        include: Option<IncludeScope>,
581    ) -> Result<(), ExpectationFailure> {
582        if can_use_find_route(selector) {
583            self.chunked_fill_runner(selector, text, include).await
584        } else if matches!(selector, Selector::Focused { .. }) {
585            // When the caller passes a Focused selector (e.g. via
586            // `Step::InputText` → `App::fill(&focused(), text)`), skip
587            // the pre-tap: `Focused` doesn't need a specific element,
588            // it's routing intent = "type into whatever is active,
589            // via key-event dispatch". The runner's `/fill` handler
590            // routes `_focused_` selector to daemon-level key event
591            // sending regardless of a11y-focus state — exactly the
592            // RN hidden-input case.
593            self.chunked_fill_runner(selector, text, include).await
594        } else {
595            self.tap(selector, include).await?;
596            sleep(Duration::from_millis(300)).await;
597            let focused = Selector::Focused {
598                focused: True(true),
599            };
600            self.chunked_fill_runner(&focused, text, include).await
601        }
602    }
603
604    async fn chunked_fill_runner(
605        &self,
606        selector: &Selector,
607        text: &str,
608        include: Option<IncludeScope>,
609    ) -> Result<(), ExpectationFailure> {
610        const INTER_CHAR_PAUSE_MS: u64 = 50;
611        let chars: Vec<char> = text.chars().collect();
612        if chars.len() <= 1 {
613            return self
614                .runner
615                .fill(selector, text, include)
616                .await
617                .map_err(transport_to_failure)
618                .map(|_| ());
619        }
620        for (i, ch) in chars.iter().enumerate() {
621            let chunk = ch.to_string();
622            self.runner
623                .fill(selector, &chunk, include)
624                .await
625                .map_err(transport_to_failure)?;
626            if i + 1 < chars.len() {
627                sleep(Duration::from_millis(INTER_CHAR_PAUSE_MS)).await;
628            }
629        }
630        Ok(())
631    }
632
633    /// Clear the focused / matched input.
634    ///
635    /// Selector-type dispatch (mirrors `fill()`).
636    ///
637    /// The runner `/clear` route only accepts text selectors (`selector.text`
638    /// field) or the `_focused_` magic. For Id / Label / Role / Anchor /
639    /// Text-with-modifiers selectors, host-resolve + tap the target first
640    /// so it owns keyboard focus, then clear via the `_focused_` magic
641    /// (single round-trip — clear is not chunked like fill).
642    pub async fn clear(
643        &self,
644        selector: &Selector,
645        include: Option<IncludeScope>,
646    ) -> Result<(), ExpectationFailure> {
647        if can_use_find_route(selector) {
648            self.runner
649                .clear(selector, include)
650                .await
651                .map_err(transport_to_failure)?;
652        } else {
653            self.tap(selector, include).await?;
654            sleep(Duration::from_millis(300)).await;
655            let focused = Selector::Focused {
656                focused: True(true),
657            };
658            self.runner
659                .clear(&focused, include)
660                .await
661                .map_err(transport_to_failure)?;
662        }
663        Ok(())
664    }
665
666    /// `POST /press-key` passthru.
667    pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
668        self.runner
669            .press_key(key)
670            .await
671            .map_err(transport_to_failure)?;
672        Ok(())
673    }
674
675    /// Host-side scroll-until-visible loop. Alternates
676    /// `driver.tree + resolve_selector` (host-side probe) with
677    /// `runner.swipe_once` (single-swipe runner gesture). Up to 30
678    /// swipes or 20s timeout.
679    pub async fn scroll(
680        &self,
681        selector: &Selector,
682        direction: SwipeDirection,
683    ) -> Result<(), ExpectationFailure> {
684        let start = Instant::now();
685        let timeout = Duration::from_secs(20);
686        // Build the resolver cache once outside the swipe loop so regex
687        // compile cost is paid once, not per iteration. None case =
688        // regex compile error → element-not-found fail-fast
689        // (semantically equivalent to silent-None + 30-swipe timeout,
690        // but immediate).
691        let Some(ctx) = ResolverContext::new(selector) else {
692            return Err(ExpectationFailure::new(FailureInit {
693                code: Some(FailureCode::ElementNotFound),
694                message: format!(
695                    "scroll({}, '{}'): selector pattern failed to compile",
696                    describe_selector(selector),
697                    direction
698                ),
699                selector: Some(selector.clone()),
700                hint: Some(
701                    "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
702                        .into(),
703                ),
704                ..Default::default()
705            }));
706        };
707        for i in 0..=SCROLL_MAX_SWIPES {
708            // Transport retry on tree fetch (see tap above).
709            let tree = self.tree_with_retry(None).await?;
710            if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
711                // v1.0.27 — live on-screen confirmation. Without it a
712                // below-the-fold element with a drifted snapshot frame
713                // satisfies the probe on swipe 0 and scrollUntilVisible
714                // returns WITHOUT scrolling (insight round-5 Ask 13).
715                // A refuted confirm means "exists but not on screen
716                // yet" — exactly the state another swipe should fix.
717                let matched = [node];
718                if self.confirm_on_screen(&matched, None).await {
719                    return Ok(());
720                }
721            }
722            if i == SCROLL_MAX_SWIPES || start.elapsed() > timeout {
723                let visible = collect_visible_summaries(&tree, 10);
724                let target = base_text_or_id(selector);
725                let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
726                return Err(ExpectationFailure::new(FailureInit {
727                    code: Some(FailureCode::ElementNotFound),
728                    message: format!(
729                        "scroll({}, '{}'): element not visible after {} swipes",
730                        describe_selector(selector),
731                        direction,
732                        SCROLL_MAX_SWIPES
733                    ),
734                    selector: Some(selector.clone()),
735                    visible_elements: visible,
736                    suggestions,
737                    ..Default::default()
738                }));
739            }
740            self.runner
741                .swipe_once(direction)
742                .await
743                .map_err(transport_to_failure)?;
744        }
745        Ok(())
746    }
747
748    /// `POST /swipe-once` passthru.
749    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
750        self.runner
751            .swipe_once(direction)
752            .await
753            .map_err(transport_to_failure)?;
754        Ok(())
755    }
756
757    /// `POST /hide-keyboard` passthru.
758    pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
759        self.runner
760            .hide_keyboard()
761            .await
762            .map_err(transport_to_failure)?;
763        Ok(())
764    }
765
766    /// `POST /back` passthru.
767    pub async fn back(&self) -> Result<(), ExpectationFailure> {
768        self.runner.back().await.map_err(transport_to_failure)?;
769        Ok(())
770    }
771
772    /// Tap at normalized (nx, ny) coordinates — escape hatch for
773    /// coord-based maestro yaml port (§9 #3 lift).
774    ///
775    /// (nx, ny) must be in [0, 1] (normalized to viewport). The runner
776    /// converts to device pixels via the Apple native event chain
777    /// (same path as the regular `tap()` centroid pipeline).
778    ///
779    /// **Escape hatch only — selector path (`tap(&selector)`) is the
780    /// canonical surface.** This bypasses a11y resolve entirely. See
781    /// CLAUDE.md §9 #3.
782    pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
783        self.runner
784            .tap_at_norm_coord(nx, ny)
785            .await
786            .map_err(transport_to_failure)?;
787        Ok(())
788    }
789
790    /// `POST /tap-by-id {id}` passthru — `XCUIElement.tap()` via the
791    /// XCTest gesture-recognizer chain. SwiftUI `.sheet` / `.alert` /
792    /// `.confirmationDialog` / `.fullScreenCover` dismiss buttons need
793    /// this path because the default host-HID-at-coord injects an
794    /// IOKit-level touch that doesn't fire the modal's SwiftUI binding
795    /// closure. Returns `ElementNotFound` when the runner reports
796    /// `ok=false`.
797    pub async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
798        let ok = self
799            .runner
800            .tap_by_id(id)
801            .await
802            .map_err(transport_to_failure)?;
803        if !ok {
804            return Err(ExpectationFailure::new(FailureInit {
805                code: Some(FailureCode::ElementNotFound),
806                message: format!("tap_by_id: element not found — id=\"{id}\""),
807                hint: Some(
808                    "runner XCUIQuery returned no match; check id spelling or wait for screen to settle"
809                        .into(),
810                ),
811                ..Default::default()
812            }));
813        }
814        Ok(())
815    }
816
817    /// Eval JS via the app-side WKWebView bridge. Direct HTTP POST to
818    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback) — does
819    /// NOT use the XCUITest runner. Returns the parsed JS result as a
820    /// JSON Value; surfaces bridge error / transport failure as
821    /// DriverError.
822    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
823        self.runner.webview_eval(js).await.map_err(|e| {
824            ExpectationFailure::new(FailureInit {
825                code: Some(FailureCode::DriverError),
826                message: format!("webview_eval: {e}"),
827                ..Default::default()
828            })
829        })
830    }
831
832    /// `POST /find-text-by-ocr` passthru — Apple Vision OCR over the
833    /// current XCUIScreen screenshot. Returns the matching text
834    /// observation's bounding box (UIKit normalized) or `None`.
835    pub async fn find_text_by_ocr(
836        &self,
837        text: &str,
838        locales: &[String],
839        recognition_level: &str,
840    ) -> Result<Option<OcrFrame>, ExpectationFailure> {
841        self.runner
842            .find_text_by_ocr(text, locales, recognition_level)
843            .await
844            .map_err(transport_to_failure)
845    }
846
847    /// `POST /swipe-at-norm-coord {from, to}` passthru — escape hatch
848    /// from-to swipe gesture sibling to [`Self::tap_at_norm_coord`].
849    /// `from` / `to` are normalized to viewport `(0, 1)`. §9 #3 lift
850    /// companion to `tap_at_coord`.
851    pub async fn swipe_at_norm_coord(
852        &self,
853        from: (f64, f64),
854        to: (f64, f64),
855    ) -> Result<(), ExpectationFailure> {
856        self.runner
857            .swipe_at_norm_coord(from, to)
858            .await
859            .map_err(transport_to_failure)?;
860        Ok(())
861    }
862
863    /// `POST /foreground {bundleId}` passthru.
864    pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
865        self.runner
866            .foreground(bundle_id)
867            .await
868            .map_err(transport_to_failure)?;
869        Ok(())
870    }
871
872    // ---- wait ----------------------------------------------------------
873
874    /// Poll until the selector resolves to a node, or timeout fires.
875    /// Returns the matched node (cloned). 5s default budget, 250 ms
876    /// poll interval.
877    pub async fn wait_for(
878        &self,
879        selector: &Selector,
880        timeout: Duration,
881        include: Option<IncludeScope>,
882    ) -> Result<A11yNode, ExpectationFailure> {
883        let start = Instant::now();
884        // Transient tree() transport errors (e.g. sim still launching →
885        // /tree returns 500 snapshot_unavailable) are treated the same
886        // as selector-not-found — retry within the timeout budget,
887        // surface only the last error on timeout. This matches the
888        // semantic intent of wait_for ("wait until ready or timeout")
889        // and is required for callers that ride out sim boot transients.
890        //
891        // Build the resolver cache once outside the poll loop. None
892        // case = regex compile error → Timeout fail with an explicit
893        // compile-error hint (semantically equivalent to silent-None
894        // + budget exhaustion, but immediate and actionable).
895        let Some(ctx) = ResolverContext::new(selector) else {
896            return Err(ExpectationFailure::new(FailureInit {
897                code: Some(FailureCode::Timeout),
898                message: format!(
899                    "waitFor({}): selector pattern failed to compile",
900                    describe_selector(selector)
901                ),
902                selector: Some(selector.clone()),
903                hint: Some(
904                    "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
905                        .into(),
906                ),
907                ..Default::default()
908            }));
909        };
910        let mut last_transport_err: Option<ExpectationFailure> = None;
911        // v1.0.27 — tracks "the tree matched but the live on-screen
912        // check refuted it" across poll iterations so the timeout
913        // failure can say WHY the wait never greened (below-the-fold
914        // element with a drifted snapshot frame — insight round-5
915        // Ask 13's wait-pass → tap-miss pair).
916        let mut tree_hit_offscreen = false;
917        loop {
918            match self.tree(include).await {
919                Ok(tree) => {
920                    if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
921                        // v1.0.27 — live on-screen confirmation.
922                        // Snapshot frames drift under iOS 26.5 + RN
923                        // Fabric; the resolver's frame∩viewport filter
924                        // can pass an element that is actually below
925                        // the fold. One live probe per tree hit keeps
926                        // wait_for / scrollUntilVisible / tapOn in
927                        // agreement on "visible".
928                        let matched = [node];
929                        if self.confirm_on_screen(&matched, include).await {
930                            return Ok(node.clone());
931                        }
932                        tree_hit_offscreen = true;
933                    }
934                    if start.elapsed() >= timeout {
935                        let visible = collect_visible_summaries(&tree, 10);
936                        let target = base_text_or_id(selector);
937                        let suggestions =
938                            smix_error::build_suggestions(target.as_deref(), &visible);
939                        let hint = if tree_hit_offscreen {
940                            Some(
941                                "the a11y tree matched this selector but the LIVE \
942                                 on-screen check refuted it every time — the element \
943                                 exists with a stale/drifted snapshot frame (typically \
944                                 below the fold on iOS 26.5 + RN Fabric). Use \
945                                 scrollUntilVisible to bring it into the viewport \
946                                 first, or an ocrText tier to assert by pixels."
947                                    .to_string(),
948                            )
949                        } else {
950                            None
951                        };
952                        return Err(ExpectationFailure::new(FailureInit {
953                            code: Some(FailureCode::Timeout),
954                            message: format!(
955                                "waitFor({}) timed out after {:?}",
956                                describe_selector(selector),
957                                timeout
958                            ),
959                            selector: Some(selector.clone()),
960                            visible_elements: visible,
961                            suggestions,
962                            hint,
963                            ..Default::default()
964                        }));
965                    }
966                    last_transport_err = None;
967                }
968                Err(e) => {
969                    if start.elapsed() >= timeout {
970                        return Err(last_transport_err.unwrap_or(e));
971                    }
972                    last_transport_err = Some(e);
973                }
974            }
975            sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
976        }
977    }
978
979    // ---- lifecycle -----------------------------------------------------
980
981    /// Idempotent close hook. The current implementation has no
982    /// sidecar / no cache to tear down; reserved for future use.
983    pub async fn dispose(&self) -> Result<(), ExpectationFailure> {
984        Ok(())
985    }
986}
987
988fn transport_to_failure(e: RunnerTransportError) -> ExpectationFailure {
989    let (code, hint) = match &e {
990        RunnerTransportError::Unreachable { .. } => (
991            FailureCode::DriverError,
992            Some("start the runner first: bash scripts/smix-runner-health.sh".to_string()),
993        ),
994        // The runner can't snapshot the target app, either because
995        // it's not foreground or because XCUITest bound to a different
996        // bundle. Emit an AI-readable hint that names the resolution
997        // channel.
998        RunnerTransportError::AppUnavailable { target, reason, .. } => (
999            FailureCode::DriverError,
1000            Some(format!(
1001                "runner reports snapshot_unavailable — target={} reason={}. \
1002                 Fix by (a) `smix run --bundle-id <BUNDLE>` so the client sends \
1003                 App-Bundle-Id header, or (b) `smix run --activate` so the runner \
1004                 auto-activates the target before snapshot, or (c) foreground the \
1005                 target app before invocation.",
1006                target.as_deref().unwrap_or("<unknown>"),
1007                reason.as_deref().unwrap_or("<no reason>"),
1008            )),
1009        ),
1010        _ => (FailureCode::DriverError, None),
1011    };
1012    ExpectationFailure::new(FailureInit {
1013        code: Some(code),
1014        message: format!("{e}"),
1015        hint,
1016        ..Default::default()
1017    })
1018}
1019
1020/// Extract the base text or id pattern from a Selector for suggestion
1021/// generation. Returns the literal string of the base form's pattern, or
1022/// None for selectors whose base doesn't have a literal target (anchor,
1023/// role-only, focused).
1024fn base_text_or_id(selector: &Selector) -> Option<String> {
1025    match selector {
1026        Selector::Text { text, .. } => match text {
1027            Pattern::Text(s) => Some(s.clone()),
1028            Pattern::Regex { regex, .. } => Some(regex.clone()),
1029        },
1030        Selector::Id { id, .. } => Some(id.clone()),
1031        Selector::Label { label, .. } => Some(label.clone()),
1032        Selector::Role { name, .. } => name.as_ref().map(|p| match p {
1033            Pattern::Text(s) => s.clone(),
1034            Pattern::Regex { regex, .. } => regex.clone(),
1035        }),
1036        Selector::Focused { .. } | Selector::Anchor { .. } => None,
1037        // LocalizedText should be desugared to Selector::Text at the
1038        // adapter layer before reaching the driver. Return None here
1039        // as the suggestion-hint fallback.
1040        Selector::LocalizedText { localized_text, .. } => {
1041            // Best-effort: return the "en" entry as a hint for AI-readable
1042            // suggestion output if available; else first table entry.
1043            localized_text
1044                .get("en")
1045                .or_else(|| localized_text.values().next())
1046                .cloned()
1047        }
1048        // The OcrText adapter dispatches OCR + tap_at_coord directly
1049        // and does not go through the driver host-resolve path.
1050        // base_text_or_id still returns ocr_text as an AI-readable
1051        // suggestion hint (e.g. for fallback-chain error reports).
1052        Selector::OcrText { ocr_text, .. } => Some(ocr_text.clone()),
1053        // AnchorRelative is an escape hatch family (§9 #3); the
1054        // adapter dispatches directly. Recurse into the anchor
1055        // sub-selector for the hint.
1056        Selector::AnchorRelative { anchor, .. } => base_text_or_id(anchor),
1057        // Point has no element text; report None (the suggestion hint
1058        // will look elsewhere).
1059        Selector::Point { .. } => None,
1060        Selector::Fallback { fallback } => fallback.first().and_then(base_text_or_id),
1061    }
1062}
1063
1064// Only Text selectors with no spatial / index modifiers ride the runner
1065// `/find` route. Everything else (Id / Label / Role / Focused / Anchor,
1066// or Text augmented with below / near / nth / first / last / ancestor /
1067// ...) falls back to host-resolve in `find()` above. The ancestor
1068// modifier is included here — Apple element query has no parent-chain
1069// semantic, so ancestor-bearing selectors must host-resolve.
1070fn can_use_find_route(selector: &Selector) -> bool {
1071    let Selector::Text { text, modifiers } = selector else {
1072        return false;
1073    };
1074    // v1.0.27 — regex patterns serialize as `{"regex": …, "flags": …}`
1075    // objects, which the runner /find route's decode (expecting
1076    // `selector.text` as a plain string) rejects with 400. Pre-v1.0.27
1077    // a regex Text selector dispatched here would burn the full
1078    // transport-retry budget (~8 s) and surface a DriverError instead
1079    // of evaluating. Only literal patterns ride the live route; regex
1080    // falls back to host-resolve like every other complex shape.
1081    if !matches!(text, Pattern::Text(_)) {
1082        return false;
1083    }
1084    modifiers.near.is_none()
1085        && modifiers.below.is_none()
1086        && modifiers.above.is_none()
1087        && modifiers.left_of.is_none()
1088        && modifiers.right_of.is_none()
1089        && modifiers.inside.is_none()
1090        && modifiers.ancestor.is_none()
1091        && modifiers.nth.is_none()
1092        && modifiers.first.is_none()
1093        && modifiers.last.is_none()
1094}
1095
1096// Silence unused-import warning for symbols re-exported as future hooks.
1097// `Modifiers` is used by external callers constructing selectors via
1098// the smix-selector re-export; we surface it here for SDK convenience.
1099#[doc(hidden)]
1100pub use smix_selector::Modifiers as _ModifiersReexport;
1101
1102// match_text_compiled is exported through smix-selector; surface here
1103// for downstream test / sdk convenience.
1104#[doc(hidden)]
1105pub use smix_selector::match_text_compiled as _match_text_compiled_reexport;
1106#[allow(dead_code)]
1107fn _silence_unused_imports() {
1108    // Touch symbols so unused-import warnings don't trip.
1109    let _: fn(&A11yNode, &smix_selector::CompiledPattern) -> bool = match_text_compiled;
1110    let _: Modifiers = Modifiers::default();
1111    let _: ScreenDescription = ScreenDescription::default();
1112    let _ = summarize_node;
1113}
1114
1115// ===========================================================================
1116// Cross-platform Driver trait + Android-ready architecture
1117// ===========================================================================
1118
1119mod android;
1120mod ios;
1121mod traits;
1122
1123pub use android::AndroidDriver;
1124pub use traits::{Driver, Platform};
1125
1126/// Back-compat alias. `SimctlDriver` was renamed to `IosDriver` for
1127/// cross-platform naming; this alias keeps existing imports
1128/// `use smix_driver::SimctlDriver` compiling.
1129pub type SimctlDriver = IosDriver;