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                match self.runner.find(selector, include).await {
216                    Ok(present) => return Ok(present),
217                    Err(e) => {
218                        let failure = transport_to_failure(e);
219                        if start.elapsed() >= timeout {
220                            return Err(last_transport_err.unwrap_or(failure));
221                        }
222                        last_transport_err = Some(failure);
223                    }
224                }
225                sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
226            }
227        } else {
228            let tree = self.tree_with_retry(include).await?;
229            Ok(!resolve_selector_all(&tree, selector).is_empty())
230        }
231    }
232
233    /// Transient `/tree` transport retry helper. Shared by
234    /// `find_one` / `find_all` / `find` (host-resolve branch). Mirrors
235    /// the transport-retry segment of `wait_for`: poll within
236    /// `TOTAL_TIMEOUT_MS` budget, sleep `POLL_INTERVAL_MS` between
237    /// attempts, surface only the last transport error on budget
238    /// exhaustion.
239    ///
240    /// Not folded into `wait_for` / `tap` because those loops have
241    /// additional in-loop logic (selector poll + ctx cache for
242    /// `wait_for`; `HostResolveError::NotFound` retry for `tap`) — DRY
243    /// here would harm clarity.
244    async fn tree_with_retry(
245        &self,
246        include: Option<IncludeScope>,
247    ) -> Result<A11yNode, ExpectationFailure> {
248        let start = Instant::now();
249        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
250        let mut last_transport_err: Option<ExpectationFailure> = None;
251        loop {
252            match self.tree(include).await {
253                Ok(tree) => return Ok(tree),
254                Err(e) => {
255                    if start.elapsed() >= timeout {
256                        return Err(last_transport_err.unwrap_or(e));
257                    }
258                    last_transport_err = Some(e);
259                }
260            }
261            sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
262        }
263    }
264
265    /// `GET /system-popups` passthru.
266    pub async fn system_popups(
267        &self,
268        include: Option<IncludeScope>,
269    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
270        self.runner
271            .system_popups(include)
272            .await
273            .map_err(transport_to_failure)
274    }
275
276    /// `POST /system-popup-action` passthru.
277    /// Returns `Ok(true)` when the runner matched and tapped, `Ok(false)`
278    /// on 404 not_found. Transport errors map to `ExpectationFailure`.
279    pub async fn system_popup_action(
280        &self,
281        popup_id: &str,
282        button_id: &str,
283    ) -> Result<bool, ExpectationFailure> {
284        self.runner
285            .system_popup_action(popup_id, button_id)
286            .await
287            .map_err(transport_to_failure)
288    }
289
290    // ---- act -----------------------------------------------------------
291
292    /// Tap a selector. Host-side resolve → centroid →
293    /// `runner.tap_at_norm_coord` (Apple native event chain), with a
294    /// 5s implicit wait + retry loop.
295    ///
296    /// The resolve+centroid+normalize pipeline lives in the
297    /// [`smix_host_coord_resolver`] stone; this method orchestrates the
298    /// smix-specific 5s implicit-wait loop plus AI-readable failure
299    /// rendering + runner injection.
300    pub async fn tap(
301        &self,
302        selector: &Selector,
303        include: Option<IncludeScope>,
304    ) -> Result<(), ExpectationFailure> {
305        let start = Instant::now();
306        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
307
308        let (nx, ny) = loop {
309            // Transport retry parity with wait_for / find. Tree fetch
310            // transient transport drops (runner socket refusal /
311            // concurrent-handling hiccup) are re-tried in-loop.
312            let tree = self.tree_with_retry(include).await?;
313            match resolve_to_norm_coord(&tree, selector) {
314                Ok(coord) => break coord,
315                Err(HostResolveError::NotFound) => {
316                    if start.elapsed() > timeout {
317                        let visible = collect_visible_summaries(&tree, 10);
318                        let target = base_text_or_id(selector);
319                        let suggestions =
320                            smix_error::build_suggestions(target.as_deref(), &visible);
321                        return Err(ExpectationFailure::new(FailureInit {
322                            code: Some(FailureCode::ElementNotFound),
323                            message: format!(
324                                "element not found: {}",
325                                describe_selector(selector)
326                            ),
327                            selector: Some(selector.clone()),
328                            visible_elements: visible,
329                            suggestions,
330                            hint: Some(
331                                "matched 0 nodes in the current a11y tree; check selector or wait for the screen to settle"
332                                    .into(),
333                            ),
334                            ..Default::default()
335                        }));
336                    }
337                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
338                    continue;
339                }
340                Err(HostResolveError::EmptyMatchedFrame) => {
341                    return Err(ExpectationFailure::new(FailureInit {
342                        code: Some(FailureCode::ElementNotFound),
343                        message: format!(
344                            "matched node has empty/offscreen frame: {}",
345                            describe_selector(selector)
346                        ),
347                        selector: Some(selector.clone()),
348                        hint: Some(
349                            "node bounds w*h == 0; element may be offscreen or hidden".into(),
350                        ),
351                        ..Default::default()
352                    }));
353                }
354                Err(HostResolveError::UnknownAppFrame) => {
355                    return Err(ExpectationFailure::new(FailureInit {
356                        code: Some(FailureCode::DriverError),
357                        message: format!(
358                            "tree bounds w/h ≤ 0 — unknown app frame: {}",
359                            describe_selector(selector)
360                        ),
361                        selector: Some(selector.clone()),
362                        hint: Some(
363                            "runner returned a tree with empty app frame; app may not be foregrounded"
364                                .into(),
365                        ),
366                        ..Default::default()
367                    }));
368                }
369                Err(HostResolveError::CentroidOutOfFrame { nx, ny }) => {
370                    return Err(ExpectationFailure::new(FailureInit {
371                        code: Some(FailureCode::ElementNotFound),
372                        message: format!(
373                            "matched node centroid out of app frame: {}",
374                            describe_selector(selector)
375                        ),
376                        selector: Some(selector.clone()),
377                        hint: Some(format!(
378                            "centroid (nx={:.3}, ny={:.3}) outside (0,1); element offscreen",
379                            nx, ny
380                        )),
381                        ..Default::default()
382                    }));
383                }
384            }
385        };
386
387        self.runner
388            .tap_at_norm_coord(nx, ny)
389            .await
390            .map_err(transport_to_failure)?;
391        Ok(())
392    }
393
394    /// Tap a selector via an explicit dispatch mode. Used to opt into
395    /// the runner-side `daemonProxySynthesize` path for RN Pressable
396    /// buttons whose `RCTTouchHandler` gesture recognizer does not fire
397    /// `onPress` when the host-side `tap_at_norm_coord` Apple native
398    /// event chain is used.
399    ///
400    /// `TapMode::DaemonProxySynthesize` routes through `POST /tap`
401    /// (runner resolves selector + synthesizes via
402    /// `XCTRunnerDaemonSession.daemonProxy
403    /// ._XCT_synthesizeEvent:completion:`). The existing
404    /// [`SimctlDriver::tap`] method keeps the host-resolve +
405    /// `tap_at_norm_coord` default for callers that don't need the
406    /// daemonProxy alternative.
407    pub async fn tap_with_mode(
408        &self,
409        selector: &Selector,
410        mode: TapMode,
411        include: Option<IncludeScope>,
412    ) -> Result<(), ExpectationFailure> {
413        let start = Instant::now();
414        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
415
416        loop {
417            match self.runner.tap(selector, mode, include).await {
418                Ok(_result) => return Ok(()),
419                Err(e) => {
420                    if start.elapsed() > timeout {
421                        return Err(transport_to_failure(e));
422                    }
423                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
424                    continue;
425                }
426            }
427        }
428    }
429
430    /// Double-tap a selector via swift sim-side XCUIElement.doubleTap().
431    /// 5s implicit-wait + retry on transport (mirrors
432    /// [`Self::tap_with_mode`]). Same as Maestro `doubleTapOn`.
433    pub async fn double_tap(
434        &self,
435        selector: &Selector,
436        include: Option<IncludeScope>,
437    ) -> Result<(), ExpectationFailure> {
438        let start = Instant::now();
439        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
440        loop {
441            match self.runner.double_tap(selector, include).await {
442                Ok(_result) => return Ok(()),
443                Err(e) => {
444                    if start.elapsed() > timeout {
445                        return Err(transport_to_failure(e));
446                    }
447                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
448                    continue;
449                }
450            }
451        }
452    }
453
454    /// Long-press a selector for `duration` via swift sim-side
455    /// XCUIElement.press(forDuration:). 5s implicit-wait + retry on
456    /// transport. Same as Maestro `longPressOn`.
457    pub async fn long_press(
458        &self,
459        selector: &Selector,
460        duration: Duration,
461        include: Option<IncludeScope>,
462    ) -> Result<(), ExpectationFailure> {
463        let start = Instant::now();
464        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
465        let duration_ms = duration.as_millis().min(u64::MAX as u128) as u64;
466        loop {
467            match self.runner.long_press(selector, duration_ms, include).await {
468                Ok(_result) => return Ok(()),
469                Err(e) => {
470                    if start.elapsed() > timeout {
471                        return Err(transport_to_failure(e));
472                    }
473                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
474                    continue;
475                }
476            }
477        }
478    }
479
480    /// Rotate sim via swift `XCUIDevice.shared.orientation`. Routes to
481    /// the runner-client `set_orientation` → POST /set-orientation.
482    pub async fn set_orientation(
483        &self,
484        orientation: Orientation,
485    ) -> Result<(), ExpectationFailure> {
486        self.runner
487            .set_orientation(orientation.as_wire())
488            .await
489            .map_err(transport_to_failure)?;
490        Ok(())
491    }
492
493    /// Fill text into the focused / matched input.
494    ///
495    /// **chunked-fill**: the swift runner's daemon `_XCT_sendString`
496    /// path bursts every keystroke at up to 200 chars/sec, which on
497    /// real-sim outraces React Native's `onChangeText` debounce on
498    /// the main thread — only the leading 2-3 keystrokes commit
499    /// before later ones are dropped by the JS thread reconciler.
500    /// The fix is host-side: split `text` into 1-char chunks and post
501    /// each to runner `/fill` separately, with an `INTER_CHAR_PAUSE_MS`
502    /// gap so the JS thread can flush its onChangeText callback before
503    /// the next keystroke fires.
504    ///
505    /// **selector-type dispatch** (mirrors `find()`): runner `/fill`
506    /// only accepts text selectors (`selector.text` field or the
507    /// `_focused_` magic). For Id / Label / Role / Anchor /
508    /// Text-with-modifiers selectors, host-resolve + tap the target
509    /// first to give it keyboard focus, then fill via the `_focused_`
510    /// magic. Text-without-modifiers selectors take the fast path
511    /// (direct chunked fill).
512    pub async fn fill(
513        &self,
514        selector: &Selector,
515        text: &str,
516        include: Option<IncludeScope>,
517    ) -> Result<(), ExpectationFailure> {
518        if can_use_find_route(selector) {
519            self.chunked_fill_runner(selector, text, include).await
520        } else if matches!(selector, Selector::Focused { .. }) {
521            // When the caller passes a Focused selector (e.g. via
522            // `Step::InputText` → `App::fill(&focused(), text)`), skip
523            // the pre-tap: `Focused` doesn't need a specific element,
524            // it's routing intent = "type into whatever is active,
525            // via key-event dispatch". The runner's `/fill` handler
526            // routes `_focused_` selector to daemon-level key event
527            // sending regardless of a11y-focus state — exactly the
528            // RN hidden-input case.
529            self.chunked_fill_runner(selector, text, include).await
530        } else {
531            self.tap(selector, include).await?;
532            sleep(Duration::from_millis(300)).await;
533            let focused = Selector::Focused {
534                focused: True(true),
535            };
536            self.chunked_fill_runner(&focused, text, include).await
537        }
538    }
539
540    async fn chunked_fill_runner(
541        &self,
542        selector: &Selector,
543        text: &str,
544        include: Option<IncludeScope>,
545    ) -> Result<(), ExpectationFailure> {
546        const INTER_CHAR_PAUSE_MS: u64 = 50;
547        let chars: Vec<char> = text.chars().collect();
548        if chars.len() <= 1 {
549            return self
550                .runner
551                .fill(selector, text, include)
552                .await
553                .map_err(transport_to_failure)
554                .map(|_| ());
555        }
556        for (i, ch) in chars.iter().enumerate() {
557            let chunk = ch.to_string();
558            self.runner
559                .fill(selector, &chunk, include)
560                .await
561                .map_err(transport_to_failure)?;
562            if i + 1 < chars.len() {
563                sleep(Duration::from_millis(INTER_CHAR_PAUSE_MS)).await;
564            }
565        }
566        Ok(())
567    }
568
569    /// Clear the focused / matched input.
570    ///
571    /// Selector-type dispatch (mirrors `fill()`).
572    ///
573    /// The runner `/clear` route only accepts text selectors (`selector.text`
574    /// field) or the `_focused_` magic. For Id / Label / Role / Anchor /
575    /// Text-with-modifiers selectors, host-resolve + tap the target first
576    /// so it owns keyboard focus, then clear via the `_focused_` magic
577    /// (single round-trip — clear is not chunked like fill).
578    pub async fn clear(
579        &self,
580        selector: &Selector,
581        include: Option<IncludeScope>,
582    ) -> Result<(), ExpectationFailure> {
583        if can_use_find_route(selector) {
584            self.runner
585                .clear(selector, include)
586                .await
587                .map_err(transport_to_failure)?;
588        } else {
589            self.tap(selector, include).await?;
590            sleep(Duration::from_millis(300)).await;
591            let focused = Selector::Focused {
592                focused: True(true),
593            };
594            self.runner
595                .clear(&focused, include)
596                .await
597                .map_err(transport_to_failure)?;
598        }
599        Ok(())
600    }
601
602    /// `POST /press-key` passthru.
603    pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
604        self.runner
605            .press_key(key)
606            .await
607            .map_err(transport_to_failure)?;
608        Ok(())
609    }
610
611    /// Host-side scroll-until-visible loop. Alternates
612    /// `driver.tree + resolve_selector` (host-side probe) with
613    /// `runner.swipe_once` (single-swipe runner gesture). Up to 30
614    /// swipes or 20s timeout.
615    pub async fn scroll(
616        &self,
617        selector: &Selector,
618        direction: SwipeDirection,
619    ) -> Result<(), ExpectationFailure> {
620        let start = Instant::now();
621        let timeout = Duration::from_secs(20);
622        // Build the resolver cache once outside the swipe loop so regex
623        // compile cost is paid once, not per iteration. None case =
624        // regex compile error → element-not-found fail-fast
625        // (semantically equivalent to silent-None + 30-swipe timeout,
626        // but immediate).
627        let Some(ctx) = ResolverContext::new(selector) else {
628            return Err(ExpectationFailure::new(FailureInit {
629                code: Some(FailureCode::ElementNotFound),
630                message: format!(
631                    "scroll({}, '{}'): selector pattern failed to compile",
632                    describe_selector(selector),
633                    direction
634                ),
635                selector: Some(selector.clone()),
636                hint: Some(
637                    "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
638                        .into(),
639                ),
640                ..Default::default()
641            }));
642        };
643        for i in 0..=SCROLL_MAX_SWIPES {
644            // Transport retry on tree fetch (see tap above).
645            let tree = self.tree_with_retry(None).await?;
646            if resolve_selector_compiled(&tree, selector, &ctx).is_some() {
647                return Ok(());
648            }
649            if i == SCROLL_MAX_SWIPES || start.elapsed() > timeout {
650                let visible = collect_visible_summaries(&tree, 10);
651                let target = base_text_or_id(selector);
652                let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
653                return Err(ExpectationFailure::new(FailureInit {
654                    code: Some(FailureCode::ElementNotFound),
655                    message: format!(
656                        "scroll({}, '{}'): element not visible after {} swipes",
657                        describe_selector(selector),
658                        direction,
659                        SCROLL_MAX_SWIPES
660                    ),
661                    selector: Some(selector.clone()),
662                    visible_elements: visible,
663                    suggestions,
664                    ..Default::default()
665                }));
666            }
667            self.runner
668                .swipe_once(direction)
669                .await
670                .map_err(transport_to_failure)?;
671        }
672        Ok(())
673    }
674
675    /// `POST /swipe-once` passthru.
676    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
677        self.runner
678            .swipe_once(direction)
679            .await
680            .map_err(transport_to_failure)?;
681        Ok(())
682    }
683
684    /// `POST /hide-keyboard` passthru.
685    pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
686        self.runner
687            .hide_keyboard()
688            .await
689            .map_err(transport_to_failure)?;
690        Ok(())
691    }
692
693    /// `POST /back` passthru.
694    pub async fn back(&self) -> Result<(), ExpectationFailure> {
695        self.runner.back().await.map_err(transport_to_failure)?;
696        Ok(())
697    }
698
699    /// Tap at normalized (nx, ny) coordinates — escape hatch for
700    /// coord-based maestro yaml port (§9 #3 lift).
701    ///
702    /// (nx, ny) must be in [0, 1] (normalized to viewport). The runner
703    /// converts to device pixels via the Apple native event chain
704    /// (same path as the regular `tap()` centroid pipeline).
705    ///
706    /// **Escape hatch only — selector path (`tap(&selector)`) is the
707    /// canonical surface.** This bypasses a11y resolve entirely. See
708    /// CLAUDE.md §9 #3.
709    pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
710        self.runner
711            .tap_at_norm_coord(nx, ny)
712            .await
713            .map_err(transport_to_failure)?;
714        Ok(())
715    }
716
717    /// `POST /tap-by-id {id}` passthru — `XCUIElement.tap()` via the
718    /// XCTest gesture-recognizer chain. SwiftUI `.sheet` / `.alert` /
719    /// `.confirmationDialog` / `.fullScreenCover` dismiss buttons need
720    /// this path because the default host-HID-at-coord injects an
721    /// IOKit-level touch that doesn't fire the modal's SwiftUI binding
722    /// closure. Returns `ElementNotFound` when the runner reports
723    /// `ok=false`.
724    pub async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
725        let ok = self
726            .runner
727            .tap_by_id(id)
728            .await
729            .map_err(transport_to_failure)?;
730        if !ok {
731            return Err(ExpectationFailure::new(FailureInit {
732                code: Some(FailureCode::ElementNotFound),
733                message: format!("tap_by_id: element not found — id=\"{id}\""),
734                hint: Some(
735                    "runner XCUIQuery returned no match; check id spelling or wait for screen to settle"
736                        .into(),
737                ),
738                ..Default::default()
739            }));
740        }
741        Ok(())
742    }
743
744    /// Eval JS via the app-side WKWebView bridge. Direct HTTP POST to
745    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback) — does
746    /// NOT use the XCUITest runner. Returns the parsed JS result as a
747    /// JSON Value; surfaces bridge error / transport failure as
748    /// DriverError.
749    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
750        self.runner.webview_eval(js).await.map_err(|e| {
751            ExpectationFailure::new(FailureInit {
752                code: Some(FailureCode::DriverError),
753                message: format!("webview_eval: {e}"),
754                ..Default::default()
755            })
756        })
757    }
758
759    /// `POST /find-text-by-ocr` passthru — Apple Vision OCR over the
760    /// current XCUIScreen screenshot. Returns the matching text
761    /// observation's bounding box (UIKit normalized) or `None`.
762    pub async fn find_text_by_ocr(
763        &self,
764        text: &str,
765        locales: &[String],
766        recognition_level: &str,
767    ) -> Result<Option<OcrFrame>, ExpectationFailure> {
768        self.runner
769            .find_text_by_ocr(text, locales, recognition_level)
770            .await
771            .map_err(transport_to_failure)
772    }
773
774    /// `POST /swipe-at-norm-coord {from, to}` passthru — escape hatch
775    /// from-to swipe gesture sibling to [`Self::tap_at_norm_coord`].
776    /// `from` / `to` are normalized to viewport `(0, 1)`. §9 #3 lift
777    /// companion to `tap_at_coord`.
778    pub async fn swipe_at_norm_coord(
779        &self,
780        from: (f64, f64),
781        to: (f64, f64),
782    ) -> Result<(), ExpectationFailure> {
783        self.runner
784            .swipe_at_norm_coord(from, to)
785            .await
786            .map_err(transport_to_failure)?;
787        Ok(())
788    }
789
790    /// `POST /foreground {bundleId}` passthru.
791    pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
792        self.runner
793            .foreground(bundle_id)
794            .await
795            .map_err(transport_to_failure)?;
796        Ok(())
797    }
798
799    // ---- wait ----------------------------------------------------------
800
801    /// Poll until the selector resolves to a node, or timeout fires.
802    /// Returns the matched node (cloned). 5s default budget, 250 ms
803    /// poll interval.
804    pub async fn wait_for(
805        &self,
806        selector: &Selector,
807        timeout: Duration,
808        include: Option<IncludeScope>,
809    ) -> Result<A11yNode, ExpectationFailure> {
810        let start = Instant::now();
811        // Transient tree() transport errors (e.g. sim still launching →
812        // /tree returns 500 snapshot_unavailable) are treated the same
813        // as selector-not-found — retry within the timeout budget,
814        // surface only the last error on timeout. This matches the
815        // semantic intent of wait_for ("wait until ready or timeout")
816        // and is required for callers that ride out sim boot transients.
817        //
818        // Build the resolver cache once outside the poll loop. None
819        // case = regex compile error → Timeout fail with an explicit
820        // compile-error hint (semantically equivalent to silent-None
821        // + budget exhaustion, but immediate and actionable).
822        let Some(ctx) = ResolverContext::new(selector) else {
823            return Err(ExpectationFailure::new(FailureInit {
824                code: Some(FailureCode::Timeout),
825                message: format!(
826                    "waitFor({}): selector pattern failed to compile",
827                    describe_selector(selector)
828                ),
829                selector: Some(selector.clone()),
830                hint: Some(
831                    "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
832                        .into(),
833                ),
834                ..Default::default()
835            }));
836        };
837        let mut last_transport_err: Option<ExpectationFailure> = None;
838        loop {
839            match self.tree(include).await {
840                Ok(tree) => {
841                    if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
842                        return Ok(node.clone());
843                    }
844                    if start.elapsed() >= timeout {
845                        let visible = collect_visible_summaries(&tree, 10);
846                        let target = base_text_or_id(selector);
847                        let suggestions =
848                            smix_error::build_suggestions(target.as_deref(), &visible);
849                        return Err(ExpectationFailure::new(FailureInit {
850                            code: Some(FailureCode::Timeout),
851                            message: format!(
852                                "waitFor({}) timed out after {:?}",
853                                describe_selector(selector),
854                                timeout
855                            ),
856                            selector: Some(selector.clone()),
857                            visible_elements: visible,
858                            suggestions,
859                            ..Default::default()
860                        }));
861                    }
862                    last_transport_err = None;
863                }
864                Err(e) => {
865                    if start.elapsed() >= timeout {
866                        return Err(last_transport_err.unwrap_or(e));
867                    }
868                    last_transport_err = Some(e);
869                }
870            }
871            sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
872        }
873    }
874
875    // ---- lifecycle -----------------------------------------------------
876
877    /// Idempotent close hook. The current implementation has no
878    /// sidecar / no cache to tear down; reserved for future use.
879    pub async fn dispose(&self) -> Result<(), ExpectationFailure> {
880        Ok(())
881    }
882}
883
884fn transport_to_failure(e: RunnerTransportError) -> ExpectationFailure {
885    let (code, hint) = match &e {
886        RunnerTransportError::Unreachable { .. } => (
887            FailureCode::DriverError,
888            Some("start the runner first: bash scripts/smix-runner-health.sh".to_string()),
889        ),
890        // The runner can't snapshot the target app, either because
891        // it's not foreground or because XCUITest bound to a different
892        // bundle. Emit an AI-readable hint that names the resolution
893        // channel.
894        RunnerTransportError::AppUnavailable { target, reason, .. } => (
895            FailureCode::DriverError,
896            Some(format!(
897                "runner reports snapshot_unavailable — target={} reason={}. \
898                 Fix by (a) `smix run --bundle-id <BUNDLE>` so the client sends \
899                 App-Bundle-Id header, or (b) `smix run --activate` so the runner \
900                 auto-activates the target before snapshot, or (c) foreground the \
901                 target app before invocation.",
902                target.as_deref().unwrap_or("<unknown>"),
903                reason.as_deref().unwrap_or("<no reason>"),
904            )),
905        ),
906        _ => (FailureCode::DriverError, None),
907    };
908    ExpectationFailure::new(FailureInit {
909        code: Some(code),
910        message: format!("{e}"),
911        hint,
912        ..Default::default()
913    })
914}
915
916/// Extract the base text or id pattern from a Selector for suggestion
917/// generation. Returns the literal string of the base form's pattern, or
918/// None for selectors whose base doesn't have a literal target (anchor,
919/// role-only, focused).
920fn base_text_or_id(selector: &Selector) -> Option<String> {
921    match selector {
922        Selector::Text { text, .. } => match text {
923            Pattern::Text(s) => Some(s.clone()),
924            Pattern::Regex { regex, .. } => Some(regex.clone()),
925        },
926        Selector::Id { id, .. } => Some(id.clone()),
927        Selector::Label { label, .. } => Some(label.clone()),
928        Selector::Role { name, .. } => name.as_ref().map(|p| match p {
929            Pattern::Text(s) => s.clone(),
930            Pattern::Regex { regex, .. } => regex.clone(),
931        }),
932        Selector::Focused { .. } | Selector::Anchor { .. } => None,
933        // LocalizedText should be desugared to Selector::Text at the
934        // adapter layer before reaching the driver. Return None here
935        // as the suggestion-hint fallback.
936        Selector::LocalizedText { localized_text, .. } => {
937            // Best-effort: return the "en" entry as a hint for AI-readable
938            // suggestion output if available; else first table entry.
939            localized_text
940                .get("en")
941                .or_else(|| localized_text.values().next())
942                .cloned()
943        }
944        // The OcrText adapter dispatches OCR + tap_at_coord directly
945        // and does not go through the driver host-resolve path.
946        // base_text_or_id still returns ocr_text as an AI-readable
947        // suggestion hint (e.g. for fallback-chain error reports).
948        Selector::OcrText { ocr_text, .. } => Some(ocr_text.clone()),
949        // AnchorRelative is an escape hatch family (§9 #3); the
950        // adapter dispatches directly. Recurse into the anchor
951        // sub-selector for the hint.
952        Selector::AnchorRelative { anchor, .. } => base_text_or_id(anchor),
953        // Point has no element text; report None (the suggestion hint
954        // will look elsewhere).
955        Selector::Point { .. } => None,
956        Selector::Fallback { fallback } => fallback.first().and_then(base_text_or_id),
957    }
958}
959
960// Only Text selectors with no spatial / index modifiers ride the runner
961// `/find` route. Everything else (Id / Label / Role / Focused / Anchor,
962// or Text augmented with below / near / nth / first / last / ancestor /
963// ...) falls back to host-resolve in `find()` above. The ancestor
964// modifier is included here — Apple element query has no parent-chain
965// semantic, so ancestor-bearing selectors must host-resolve.
966fn can_use_find_route(selector: &Selector) -> bool {
967    let Selector::Text { modifiers, .. } = selector else {
968        return false;
969    };
970    modifiers.near.is_none()
971        && modifiers.below.is_none()
972        && modifiers.above.is_none()
973        && modifiers.left_of.is_none()
974        && modifiers.right_of.is_none()
975        && modifiers.inside.is_none()
976        && modifiers.ancestor.is_none()
977        && modifiers.nth.is_none()
978        && modifiers.first.is_none()
979        && modifiers.last.is_none()
980}
981
982// Silence unused-import warning for symbols re-exported as future hooks.
983// `Modifiers` is used by external callers constructing selectors via
984// the smix-selector re-export; we surface it here for SDK convenience.
985#[doc(hidden)]
986pub use smix_selector::Modifiers as _ModifiersReexport;
987
988// match_text_compiled is exported through smix-selector; surface here
989// for downstream test / sdk convenience.
990#[doc(hidden)]
991pub use smix_selector::match_text_compiled as _match_text_compiled_reexport;
992#[allow(dead_code)]
993fn _silence_unused_imports() {
994    // Touch symbols so unused-import warnings don't trip.
995    let _: fn(&A11yNode, &smix_selector::CompiledPattern) -> bool = match_text_compiled;
996    let _: Modifiers = Modifiers::default();
997    let _: ScreenDescription = ScreenDescription::default();
998    let _ = summarize_node;
999}
1000
1001// ===========================================================================
1002// Cross-platform Driver trait + Android-ready architecture
1003// ===========================================================================
1004
1005mod android;
1006mod ios;
1007mod traits;
1008
1009pub use android::AndroidDriver;
1010pub use traits::{Driver, Platform};
1011
1012/// Back-compat alias. `SimctlDriver` was renamed to `IosDriver` for
1013/// cross-platform naming; this alias keeps existing imports
1014/// `use smix_driver::SimctlDriver` compiling.
1015pub type SimctlDriver = IosDriver;