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. Path B is default since v1.5 c5i-a S6: SDK call →
5//! `driver.tap` → `tree()` → `resolve_selector()` → centroid →
6//! `runner.tap_at_norm_coord()` (v1.6 c5 Apple native event chain).
7//!
8//! Ported from now-retired TS source: `src/driver/simctl-driver.ts` (only the
9//! runner-client-bound methods; simctl child_process methods like
10//! `launch / terminate / install / pasteboard / grantPermission` land
11//! in c10 `smix-simctl` outer crate).
12//!
13//! # Failure model
14//!
15//! All methods return `Result<T, ExpectationFailure>` (跟 TS throws +
16//! AI-readable rendering via smix-error).
17//!
18//! # Implicit wait (v1.5 c5i-d)
19//!
20//! `tap` includes a 5s poll-and-retry loop (跟 TS line 754-755 wait-and-
21//! tap merged pattern): if the first `resolve_selector` returns None,
22//! sleep 250 ms then re-fetch tree + re-resolve, up to a 5s total
23//! budget. Once a candidate is found we tap **the same frame** to avoid
24//! split-fetch race (跟 TS line 747-753 注释 同源).
25
26#![doc(html_root_url = "https://docs.smix.dev/smix-driver")]
27
28use smix_error::{ExpectationFailure, FailureCode, FailureInit};
29use smix_host_coord_resolver::{HostResolveError, resolve_to_norm_coord};
30use smix_input::{KeyName, SwipeDirection};
31use smix_screen::{
32    A11yNode, DEFAULT_VISIBLE_LIMIT, ScreenDescription, collect_visible_summaries, summarize_node,
33};
34use smix_selector::{Modifiers, Pattern, Selector, True, describe_selector, match_text_compiled};
35use smix_selector_resolver::{
36    ResolverContext, resolve_selector, resolve_selector_all, resolve_selector_compiled,
37};
38use std::time::{Duration, Instant};
39use tokio::time::sleep;
40
41/// v5.2 c5 — sim device orientation. Driver-level type; SDK exposes a
42/// 1:1 `MaestroOrientation` mirror for maestro yaml literal alignment.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub enum Orientation {
45    /// Standard upright portrait.
46    Portrait,
47    /// Upside-down portrait (home indicator at top).
48    PortraitUpsideDown,
49    /// Landscape with home indicator to the right.
50    LandscapeLeft,
51    /// Landscape with home indicator to the left.
52    LandscapeRight,
53}
54
55impl Orientation {
56    /// Wire literal sent to swift handler (`XCUIDevice.shared.orientation`
57    /// switch mapping).
58    pub fn as_wire(self) -> &'static str {
59        match self {
60            Self::Portrait => "portrait",
61            Self::PortraitUpsideDown => "portraitUpsideDown",
62            Self::LandscapeLeft => "landscapeLeft",
63            Self::LandscapeRight => "landscapeRight",
64        }
65    }
66}
67
68pub use smix_runner_client::{
69    HttpRunnerClient, IncludeScope, OcrFrame, RunnerScrollSelector, RunnerTransportError,
70    SystemPopup, TapMode,
71};
72
73const POLL_INTERVAL_MS: u64 = 250;
74const TOTAL_TIMEOUT_MS: u64 = 5000;
75const SCROLL_MAX_SWIPES: u32 = 30;
76
77/// v6.0 c1a — renamed from `SimctlDriver` to `IosDriver` to make platform
78/// explicit in the cross-platform Driver trait architecture (see
79/// `docs/plan-cold/v6-cross-platform-yaml-design.md` §7). Type alias
80/// `pub type SimctlDriver = IosDriver` (in lib.rs re-export) keeps
81/// existing v5.x callers working without source-edit ripple.
82///
83/// Driver wrapping `HttpRunnerClient` with host-side resolve dispatch.
84pub struct IosDriver {
85    runner: HttpRunnerClient,
86}
87
88impl IosDriver {
89    pub fn new(runner: HttpRunnerClient) -> Self {
90        IosDriver { runner }
91    }
92
93    pub fn runner(&self) -> &HttpRunnerClient {
94        &self.runner
95    }
96
97    /// v0.2.1 — mutable accessor for the wrapped client. Used by the
98    /// Driver-trait pass-through (`set_target_bundle_id` /
99    /// `set_auto_activate`) so the client's per-request context can be
100    /// mutated after driver construction.
101    pub fn runner_mut(&mut self) -> &mut HttpRunnerClient {
102        &mut self.runner
103    }
104
105    /// v0.2.1 — set the target bundle id sent to the runner on every
106    /// request. Threads `--bundle-id` from the CLI (via `FlowArgs`)
107    /// down to the wire so the runner's per-request rebind logic can
108    /// resolve to the right XCUIApplication. gol-611-v0.2.1 §Phase C.
109    #[must_use]
110    pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
111        self.runner = self.runner.with_target_bundle_id(bundle);
112        self
113    }
114
115    /// v0.2.1 — enable `App-Activate: true` on every request. Forces
116    /// the runner to call `.activate()` on the resolved target before
117    /// each operation. Wired from `smix run --activate`.
118    #[must_use]
119    pub fn with_auto_activate(mut self, activate: bool) -> Self {
120        self.runner = self.runner.with_auto_activate(activate);
121        self
122    }
123
124    // ---- sense ---------------------------------------------------------
125
126    /// Fetch full a11y tree (passthru to `GET /tree`).
127    pub async fn tree(
128        &self,
129        include: Option<IncludeScope>,
130    ) -> Result<A11yNode, ExpectationFailure> {
131        self.runner
132            .get_tree(include)
133            .await
134            .map_err(transport_to_failure)
135    }
136
137    /// Aggregate `ScreenDescription` (DFS visible summaries; no
138    /// screenshot here — caller adds when needed).
139    pub async fn describe(&self) -> Result<ScreenDescription, ExpectationFailure> {
140        let tree = self.tree(None).await?;
141        Ok(ScreenDescription {
142            screenshot: None,
143            elements: collect_visible_summaries(&tree, DEFAULT_VISIBLE_LIMIT),
144            front_app: String::new(),
145            summary: String::new(),
146            captured_at: 0.0,
147        })
148    }
149
150    /// Resolve selector → single node (passthru-ish: driver.tree + resolver).
151    pub async fn find_one(
152        &self,
153        selector: &Selector,
154        include: Option<IncludeScope>,
155    ) -> Result<Option<A11yNode>, ExpectationFailure> {
156        let tree = self.tree_with_retry(include).await?;
157        Ok(resolve_selector(&tree, selector).cloned())
158    }
159
160    /// v5.20 c1 — Resolve selector → its centroid as viewport-normalized
161    /// `(nx, ny)` in `[0, 1]`. `None` when selector resolves no node OR
162    /// matched node has empty / offscreen frame. Used by adapter
163    /// AnchorRelative dispatch to find an anchor's center before adding
164    /// `(dx, dy)` shift. L6 sense layer per a11y-i18n master plan §1.
165    pub async fn find_norm_coord(
166        &self,
167        selector: &Selector,
168    ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
169        let tree = self.tree_with_retry(None).await?;
170        match resolve_to_norm_coord(&tree, selector) {
171            Ok((nx, ny)) => Ok(Some((nx, ny))),
172            Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
173            Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
174                code: Some(FailureCode::DriverError),
175                message: "find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)".into(),
176                ..Default::default()
177            })),
178            Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
179        }
180    }
181
182    /// Resolve selector → all matching nodes.
183    pub async fn find_all(
184        &self,
185        selector: &Selector,
186        include: Option<IncludeScope>,
187    ) -> Result<Vec<A11yNode>, ExpectationFailure> {
188        let tree = self.tree_with_retry(include).await?;
189        Ok(resolve_selector_all(&tree, selector)
190            .into_iter()
191            .cloned()
192            .collect())
193    }
194
195    /// Boolean existence quick-probe (v1.4 + v3.5 c2 fallback).
196    ///
197    /// Selector-type dispatch:
198    /// - Text selectors with no spatial / index modifiers → runner
199    ///   `/find` route (Apple element query, no full-tree snapshot —
200    ///   the v1.9 fast path).
201    /// - Id / Label / Role / Focused / Anchor selectors, and Text
202    ///   with spatial (`below` / `near` / ...) or index (`nth` /
203    ///   `first` / `last`) modifiers → host-resolve fallback:
204    ///   `tree() + resolve_selector_all()` client-side. The runner
205    ///   `/find` route only knows how to query by text predicate, so
206    ///   anything richer has to ride on the full tree snapshot.
207    ///
208    /// v4.1 c1 — transient transport retry parity with `wait_for`. Both
209    /// dispatch branches poll within `TOTAL_TIMEOUT_MS` on `/find` 500
210    /// or `/tree` 500 (sim still launching / runner re-attach / etc),
211    /// matching the v3.17 c1 wait_for transient-retry pattern.
212    pub async fn find(
213        &self,
214        selector: &Selector,
215        include: Option<IncludeScope>,
216    ) -> Result<bool, ExpectationFailure> {
217        if can_use_find_route(selector) {
218            let start = Instant::now();
219            let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
220            let mut last_transport_err: Option<ExpectationFailure> = None;
221            loop {
222                match self.runner.find(selector, include).await {
223                    Ok(present) => return Ok(present),
224                    Err(e) => {
225                        let failure = transport_to_failure(e);
226                        if start.elapsed() >= timeout {
227                            return Err(last_transport_err.unwrap_or(failure));
228                        }
229                        last_transport_err = Some(failure);
230                    }
231                }
232                sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
233            }
234        } else {
235            let tree = self.tree_with_retry(include).await?;
236            Ok(!resolve_selector_all(&tree, selector).is_empty())
237        }
238    }
239
240    /// v4.1 c1 — transient `/tree` transport retry helper. Shared by
241    /// `find_one` / `find_all` / `find` (host-resolve branch). Mirrors
242    /// the transport-retry segment of `wait_for`: poll within
243    /// `TOTAL_TIMEOUT_MS` budget, sleep `POLL_INTERVAL_MS` between
244    /// attempts, surface only the last transport error on budget
245    /// exhaustion.
246    ///
247    /// Not folded into `wait_for` / `tap` because those loops have
248    /// additional in-loop logic (selector poll + ctx cache for
249    /// `wait_for`; `HostResolveError::NotFound` retry for `tap`) — DRY
250    /// here would harm clarity.
251    async fn tree_with_retry(
252        &self,
253        include: Option<IncludeScope>,
254    ) -> Result<A11yNode, ExpectationFailure> {
255        let start = Instant::now();
256        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
257        let mut last_transport_err: Option<ExpectationFailure> = None;
258        loop {
259            match self.tree(include).await {
260                Ok(tree) => return Ok(tree),
261                Err(e) => {
262                    if start.elapsed() >= timeout {
263                        return Err(last_transport_err.unwrap_or(e));
264                    }
265                    last_transport_err = Some(e);
266                }
267            }
268            sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
269        }
270    }
271
272    /// `GET /system-popups` passthru.
273    pub async fn system_popups(
274        &self,
275        include: Option<IncludeScope>,
276    ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
277        self.runner
278            .system_popups(include)
279            .await
280            .map_err(transport_to_failure)
281    }
282
283    /// `POST /system-popup-action` passthru (v4.2 c2 — G9 act side).
284    /// Returns `Ok(true)` when the runner matched and tapped, `Ok(false)`
285    /// on 404 not_found. Transport errors map to `ExpectationFailure`.
286    pub async fn system_popup_action(
287        &self,
288        popup_id: &str,
289        button_id: &str,
290    ) -> Result<bool, ExpectationFailure> {
291        self.runner
292            .system_popup_action(popup_id, button_id)
293            .await
294            .map_err(transport_to_failure)
295    }
296
297    // ---- act -----------------------------------------------------------
298
299    /// Tap a selector. v1.5 c5i-a S6 + v1.6 c5: host-side resolve →
300    /// centroid → runner.tap_at_norm_coord (Apple native event chain).
301    /// 5s implicit wait + retry (v1.5 c5i-d wait-and-tap merged).
302    ///
303    /// The resolve+centroid+normalize pipeline lives in the
304    /// [`smix_host_coord_resolver`] stone (v3.2 c5); this method
305    /// orchestrates the smix-specific 5s implicit-wait loop +
306    /// AI-readable failure rendering + runner injection.
307    pub async fn tap(
308        &self,
309        selector: &Selector,
310        include: Option<IncludeScope>,
311    ) -> Result<(), ExpectationFailure> {
312        let start = Instant::now();
313        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
314
315        let (nx, ny) = loop {
316            // v4.1 c4 — transport retry parity with wait_for / find. Tree
317            // fetch transient transport drops (runner socket refusal /
318            // FlyingFox concurrent-handling hiccup) caused 3/15 baseline
319            // regressions in v4.1 c3. Mirror the wait_for v3.17 c1 pattern.
320            let tree = self.tree_with_retry(include).await?;
321            match resolve_to_norm_coord(&tree, selector) {
322                Ok(coord) => break coord,
323                Err(HostResolveError::NotFound) => {
324                    if start.elapsed() > timeout {
325                        let visible = collect_visible_summaries(&tree, 10);
326                        let target = base_text_or_id(selector);
327                        let suggestions =
328                            smix_error::build_suggestions(target.as_deref(), &visible);
329                        return Err(ExpectationFailure::new(FailureInit {
330                            code: Some(FailureCode::ElementNotFound),
331                            message: format!(
332                                "element not found: {}",
333                                describe_selector(selector)
334                            ),
335                            selector: Some(selector.clone()),
336                            visible_elements: visible,
337                            suggestions,
338                            hint: Some(
339                                "matched 0 nodes in the current a11y tree; check selector or wait for the screen to settle"
340                                    .into(),
341                            ),
342                            ..Default::default()
343                        }));
344                    }
345                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
346                    continue;
347                }
348                Err(HostResolveError::EmptyMatchedFrame) => {
349                    return Err(ExpectationFailure::new(FailureInit {
350                        code: Some(FailureCode::ElementNotFound),
351                        message: format!(
352                            "matched node has empty/offscreen frame: {}",
353                            describe_selector(selector)
354                        ),
355                        selector: Some(selector.clone()),
356                        hint: Some(
357                            "node bounds w*h == 0; element may be offscreen or hidden".into(),
358                        ),
359                        ..Default::default()
360                    }));
361                }
362                Err(HostResolveError::UnknownAppFrame) => {
363                    return Err(ExpectationFailure::new(FailureInit {
364                        code: Some(FailureCode::DriverError),
365                        message: format!(
366                            "tree bounds w/h ≤ 0 — unknown app frame: {}",
367                            describe_selector(selector)
368                        ),
369                        selector: Some(selector.clone()),
370                        hint: Some(
371                            "runner returned a tree with empty app frame; app may not be foregrounded"
372                                .into(),
373                        ),
374                        ..Default::default()
375                    }));
376                }
377                Err(HostResolveError::CentroidOutOfFrame { nx, ny }) => {
378                    return Err(ExpectationFailure::new(FailureInit {
379                        code: Some(FailureCode::ElementNotFound),
380                        message: format!(
381                            "matched node centroid out of app frame: {}",
382                            describe_selector(selector)
383                        ),
384                        selector: Some(selector.clone()),
385                        hint: Some(format!(
386                            "centroid (nx={:.3}, ny={:.3}) outside (0,1); element offscreen",
387                            nx, ny
388                        )),
389                        ..Default::default()
390                    }));
391                }
392            }
393        };
394
395        self.runner
396            .tap_at_norm_coord(nx, ny)
397            .await
398            .map_err(transport_to_failure)?;
399        Ok(())
400    }
401
402    /// Tap a selector via an explicit dispatch mode. Used to opt into the
403    /// runner-side `daemonProxySynthesize` path for RN Pressable buttons
404    /// whose `RCTTouchHandler` gesture recognizer does not fire `onPress`
405    /// when the host-side `tap_at_norm_coord` Apple native event chain is
406    /// used (v4.0 c3 swift G8 fix).
407    ///
408    /// `TapMode::DaemonProxySynthesize` routes through `POST /tap` (runner
409    /// resolves selector + synthesizes via `XCTRunnerDaemonSession
410    /// .daemonProxy._XCT_synthesizeEvent:completion:`). The existing
411    /// [`SimctlDriver::tap`] method keeps the v1.6 c5 host-resolve +
412    /// `tap_at_norm_coord` default for callers that don't need the
413    /// daemonProxy alternative.
414    pub async fn tap_with_mode(
415        &self,
416        selector: &Selector,
417        mode: TapMode,
418        include: Option<IncludeScope>,
419    ) -> Result<(), ExpectationFailure> {
420        let start = Instant::now();
421        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
422
423        loop {
424            match self.runner.tap(selector, mode, include).await {
425                Ok(_result) => return Ok(()),
426                Err(e) => {
427                    if start.elapsed() > timeout {
428                        return Err(transport_to_failure(e));
429                    }
430                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
431                    continue;
432                }
433            }
434        }
435    }
436
437    /// v5.2 c3 — double-tap a selector via swift sim-side
438    /// XCUIElement.doubleTap(). 5s implicit-wait + retry on transport
439    /// (mirrors [`Self::tap_with_mode`]). Maestro `doubleTapOn` 同源.
440    pub async fn double_tap(
441        &self,
442        selector: &Selector,
443        include: Option<IncludeScope>,
444    ) -> Result<(), ExpectationFailure> {
445        let start = Instant::now();
446        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
447        loop {
448            match self.runner.double_tap(selector, include).await {
449                Ok(_result) => return Ok(()),
450                Err(e) => {
451                    if start.elapsed() > timeout {
452                        return Err(transport_to_failure(e));
453                    }
454                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
455                    continue;
456                }
457            }
458        }
459    }
460
461    /// v5.2 c3 — long-press a selector for `duration` via swift sim-side
462    /// XCUIElement.press(forDuration:). 5s implicit-wait + retry on
463    /// transport. Maestro `longPressOn` 同源.
464    pub async fn long_press(
465        &self,
466        selector: &Selector,
467        duration: Duration,
468        include: Option<IncludeScope>,
469    ) -> Result<(), ExpectationFailure> {
470        let start = Instant::now();
471        let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
472        let duration_ms = duration.as_millis().min(u64::MAX as u128) as u64;
473        loop {
474            match self.runner.long_press(selector, duration_ms, include).await {
475                Ok(_result) => return Ok(()),
476                Err(e) => {
477                    if start.elapsed() > timeout {
478                        return Err(transport_to_failure(e));
479                    }
480                    sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
481                    continue;
482                }
483            }
484        }
485    }
486
487    /// v5.2 c5 — rotate sim via swift `XCUIDevice.shared.orientation`.
488    /// Routes to runner-client `set_orientation` → POST /set-orientation.
489    pub async fn set_orientation(
490        &self,
491        orientation: Orientation,
492    ) -> Result<(), ExpectationFailure> {
493        self.runner
494            .set_orientation(orientation.as_wire())
495            .await
496            .map_err(transport_to_failure)?;
497        Ok(())
498    }
499
500    /// Fill text into the focused / matched input.
501    ///
502    /// v3.6 c1 G1 fix + c2 selector-type dispatch.
503    ///
504    /// **chunked-fill** (G1): the swift runner's daemon `_XCT_sendString`
505    /// path bursts every keystroke at up to 200 chars/sec, which on
506    /// real-sim outraces React Native's `onChangeText` debounce on the
507    /// main thread — only the leading 2-3 keystrokes commit before
508    /// later ones are dropped by the JS thread reconciler. Per
509    /// CLAUDE.md §13 + v3.x cycle constraint (swift sim-side
510    /// untouched), the fix is host-side: split `text` into 1-char
511    /// chunks and post each to runner `/fill` separately, with a
512    /// `INTER_CHAR_PAUSE_MS` gap so the JS thread can flush its
513    /// onChangeText callback before the next keystroke fires.
514    ///
515    /// **selector-type dispatch** (c2 — mirrors `find()`): runner
516    /// `/fill` only accepts text selectors (`selector.text` field or
517    /// the `_focused_` magic). For Id / Label / Role / Anchor /
518    /// Text-with-modifiers selectors, host-resolve + tap the target
519    /// first to give it keyboard focus, then fill via the `_focused_`
520    /// magic. Text-without-modifiers selectors take the fast path
521    /// (direct chunked fill).
522    pub async fn fill(
523        &self,
524        selector: &Selector,
525        text: &str,
526        include: Option<IncludeScope>,
527    ) -> Result<(), ExpectationFailure> {
528        if can_use_find_route(selector) {
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    /// v3.7 c1 — selector-type dispatch (mirrors `fill()` v3.6 c2).
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 (v1.5 c5i-d). Alternates
612    /// `driver.tree + resolve_selector` (host-side probe) with
613    /// `runner.swipe_once` (single-swipe runner gesture). Up to 30 swipes
614    /// 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        // v3.31 c1 — build cache once outside the swipe loop so regex
623        // compile cost is paid once, not per iteration. None case = regex
624        // compile error → element-not-found fail-fast (semantically
625        // equivalent to silent-None + 30-swipe timeout, but immediate).
626        let Some(ctx) = ResolverContext::new(selector) else {
627            return Err(ExpectationFailure::new(FailureInit {
628                code: Some(FailureCode::ElementNotFound),
629                message: format!(
630                    "scroll({}, '{}'): selector pattern failed to compile",
631                    describe_selector(selector),
632                    direction
633                ),
634                selector: Some(selector.clone()),
635                hint: Some(
636                    "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
637                        .into(),
638                ),
639                ..Default::default()
640            }));
641        };
642        for i in 0..=SCROLL_MAX_SWIPES {
643            // v4.1 c4 — transport retry on tree fetch (see tap above).
644            let tree = self.tree_with_retry(None).await?;
645            if resolve_selector_compiled(&tree, selector, &ctx).is_some() {
646                return Ok(());
647            }
648            if i == SCROLL_MAX_SWIPES || start.elapsed() > timeout {
649                let visible = collect_visible_summaries(&tree, 10);
650                let target = base_text_or_id(selector);
651                let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
652                return Err(ExpectationFailure::new(FailureInit {
653                    code: Some(FailureCode::ElementNotFound),
654                    message: format!(
655                        "scroll({}, '{}'): element not visible after {} swipes",
656                        describe_selector(selector),
657                        direction,
658                        SCROLL_MAX_SWIPES
659                    ),
660                    selector: Some(selector.clone()),
661                    visible_elements: visible,
662                    suggestions,
663                    ..Default::default()
664                }));
665            }
666            self.runner
667                .swipe_once(direction)
668                .await
669                .map_err(transport_to_failure)?;
670        }
671        Ok(())
672    }
673
674    /// `POST /swipe-once` passthru.
675    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
676        self.runner
677            .swipe_once(direction)
678            .await
679            .map_err(transport_to_failure)?;
680        Ok(())
681    }
682
683    /// `POST /hide-keyboard` passthru.
684    pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
685        self.runner
686            .hide_keyboard()
687            .await
688            .map_err(transport_to_failure)?;
689        Ok(())
690    }
691
692    /// `POST /back` passthru.
693    pub async fn back(&self) -> Result<(), ExpectationFailure> {
694        self.runner.back().await.map_err(transport_to_failure)?;
695        Ok(())
696    }
697
698    /// Tap at normalized (nx, ny) coordinates — escape hatch for
699    /// coord-based maestro yaml port (v3.16 §9 #3 lift; counting-fullscreen
700    /// `point: "50%,95%"` + merlin-input `point: "50%,90%"` etc.).
701    ///
702    /// (nx, ny) must be in [0, 1] (normalized to viewport). The runner
703    /// converts to device pixels via Apple native event chain (same path
704    /// as the regular `tap()` centroid pipeline since v1.6 c5).
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 (v5.3 c4) — `XCUIElement.tap()` via
718    /// the XCTest gesture-recognizer chain. SwiftUI `.sheet` / `.alert` /
719    /// `.confirmationDialog` / `.fullScreenCover` dismiss buttons need this
720    /// path because the default host-HID-at-coord injects an IOKit-level
721    /// touch that doesn't fire the modal's SwiftUI binding closure.
722    /// Returns `ElementNotFound` when the runner reports `ok=false`.
723    pub async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
724        let ok = self
725            .runner
726            .tap_by_id(id)
727            .await
728            .map_err(transport_to_failure)?;
729        if !ok {
730            return Err(ExpectationFailure::new(FailureInit {
731                code: Some(FailureCode::ElementNotFound),
732                message: format!("tap_by_id: element not found — id=\"{id}\""),
733                hint: Some(
734                    "runner XCUIQuery returned no match; check id spelling or wait for screen to settle"
735                        .into(),
736                ),
737                ..Default::default()
738            }));
739        }
740        Ok(())
741    }
742
743    /// v5.21 c1b — Eval JS via fixture-side WKWebView bridge (Option A
744    /// per a11y-i18n master plan §1 L5+). Direct HTTP POST to
745    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback) — does NOT
746    /// use XCUITest runner. Returns parsed JS result as JSON Value;
747    /// surfaces bridge error / transport failure as DriverError.
748    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
749        self.runner.webview_eval(js).await.map_err(|e| {
750            ExpectationFailure::new(FailureInit {
751                code: Some(FailureCode::DriverError),
752                message: format!("webview_eval: {e}"),
753                ..Default::default()
754            })
755        })
756    }
757
758    /// `POST /find-text-by-ocr` passthru (v5.19 c1) — Apple Vision OCR
759    /// over current XCUIScreen screenshot. Returns the matching text
760    /// observation's bounding box (UIKit normalized) or `None`. L5 sense
761    /// layer per a11y-i18n master plan §1.
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)`. v5.2 c1 §9 #3
777    /// lift 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 selector resolves to a node, or timeout fires. Returns
802    /// the matched node (cloned). v0.3 C6 contract — 5s default budget,
803    /// 250 ms poll interval. Mirrors TS `waitFor` 1:1.
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        // v3.17 c1: transient tree() transport errors (e.g. sim still
812        // launching → /tree returns 500 snapshot_unavailable) are treated
813        // the same as selector-not-found — retry within 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 example launch_warm to ride out sim
817        // boot transients.
818        // v3.31 c1 — build cache once outside the poll loop. None case =
819        // regex compile error → Timeout fail with explicit compile-error
820        // hint (semantically equivalent to silent-None + budget
821        // 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 (跟 TS R5.a `dispose` 同). Current Rust impl
878    /// has no sidecar / no cache to tear down; reserved for future
879    /// HostHidSidecar wrapper port if/when that path returns.
880    pub async fn dispose(&self) -> Result<(), ExpectationFailure> {
881        Ok(())
882    }
883}
884
885fn transport_to_failure(e: RunnerTransportError) -> ExpectationFailure {
886    let (code, hint) = match &e {
887        RunnerTransportError::Unreachable { .. } => (
888            FailureCode::DriverError,
889            Some("start the runner first: bash scripts/smix-runner-health.sh".to_string()),
890        ),
891        // v0.2.1 — gol-611-v0.2.1: the runner can't snapshot the target app,
892        // either because it's not foreground or because XCUITest bound to a
893        // different bundle. Emit an AI-readable hint that names the
894        // resolution channel.
895        RunnerTransportError::AppUnavailable { target, reason, .. } => (
896            FailureCode::DriverError,
897            Some(format!(
898                "runner reports snapshot_unavailable — target={} reason={}. \
899                 Fix by (a) `smix run --bundle-id <BUNDLE>` so the client sends \
900                 App-Bundle-Id header, or (b) `smix run --activate` so the runner \
901                 auto-activates the target before snapshot, or (c) foreground the \
902                 target app before invocation.",
903                target.as_deref().unwrap_or("<unknown>"),
904                reason.as_deref().unwrap_or("<no reason>"),
905            )),
906        ),
907        _ => (FailureCode::DriverError, None),
908    };
909    ExpectationFailure::new(FailureInit {
910        code: Some(code),
911        message: format!("{e}"),
912        hint,
913        ..Default::default()
914    })
915}
916
917/// Extract the base text or id pattern from a Selector for suggestion
918/// generation. Returns the literal string of the base form's pattern, or
919/// None for selectors whose base doesn't have a literal target (anchor,
920/// role-only, focused).
921fn base_text_or_id(selector: &Selector) -> Option<String> {
922    match selector {
923        Selector::Text { text, .. } => match text {
924            Pattern::Text(s) => Some(s.clone()),
925            Pattern::Regex { regex, .. } => Some(regex.clone()),
926        },
927        Selector::Id { id, .. } => Some(id.clone()),
928        Selector::Label { label, .. } => Some(label.clone()),
929        Selector::Role { name, .. } => name.as_ref().map(|p| match p {
930            Pattern::Text(s) => s.clone(),
931            Pattern::Regex { regex, .. } => regex.clone(),
932        }),
933        Selector::Focused { .. } | Selector::Anchor { .. } => None,
934        // v5.18 c1 — LocalizedText 应在 adapter 层 desugar 成 Selector::Text
935        // 后才到 driver. 这里返 None (用作 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        // v5.19 c1 — OcrText adapter dispatches OCR + tap_at_coord directly,
945        // 不走 driver host-resolve path. base_text_or_id 仍返 ocr_text 作 AI-
946        // readable suggestion hint (e.g. for fallback chain error reports).
947        Selector::OcrText { ocr_text, .. } => Some(ocr_text.clone()),
948        // v5.20 c1 — AnchorRelative is escape hatch family (§9 #3); adapter
949        // dispatches directly. Recurse into anchor sub-selector for hint.
950        Selector::AnchorRelative { anchor, .. } => base_text_or_id(anchor),
951        // v5.20 c2 — Point has no element text; report None (suggestion
952        // hint will look elsewhere). Fallback recurses into the first
953        // chain element as the most informative hint.
954        Selector::Point { .. } => None,
955        Selector::Fallback { fallback } => fallback.first().and_then(base_text_or_id),
956    }
957}
958
959// v3.5 c2 — only Text selectors with no spatial / index modifiers
960// ride the runner `/find` route. Everything else (Id / Label / Role /
961// Focused / Anchor, or Text augmented with below / near / nth / first /
962// last / ancestor / ...) falls back to host-resolve in `find()` above.
963// (v3.14 c1.5: ancestor modifier added — Apple element query has no
964// parent-chain semantic, so ancestor-bearing selectors must host-resolve.)
965fn can_use_find_route(selector: &Selector) -> bool {
966    let Selector::Text { modifiers, .. } = selector else {
967        return false;
968    };
969    modifiers.near.is_none()
970        && modifiers.below.is_none()
971        && modifiers.above.is_none()
972        && modifiers.left_of.is_none()
973        && modifiers.right_of.is_none()
974        && modifiers.inside.is_none()
975        && modifiers.ancestor.is_none()
976        && modifiers.nth.is_none()
977        && modifiers.first.is_none()
978        && modifiers.last.is_none()
979}
980
981// Silence unused-import warning for symbols re-exported as future hooks.
982// `Modifiers` is used by external callers constructing selectors via
983// the smix-selector re-export; we surface it here for SDK convenience.
984#[doc(hidden)]
985pub use smix_selector::Modifiers as _ModifiersReexport;
986
987// match_text_compiled is exported through smix-selector; surface here
988// for downstream test / sdk convenience.
989#[doc(hidden)]
990pub use smix_selector::match_text_compiled as _match_text_compiled_reexport;
991#[allow(dead_code)]
992fn _silence_unused_imports() {
993    // Touch symbols so unused-import warnings don't trip.
994    let _: fn(&A11yNode, &smix_selector::CompiledPattern) -> bool = match_text_compiled;
995    let _: Modifiers = Modifiers::default();
996    let _: ScreenDescription = ScreenDescription::default();
997    let _ = summarize_node;
998}
999
1000// ===========================================================================
1001// v6.0 c1a — cross-platform Driver trait + Android-ready architecture
1002// ===========================================================================
1003
1004mod android;
1005mod ios;
1006mod traits;
1007
1008pub use android::AndroidDriver;
1009pub use traits::{Driver, Platform};
1010
1011/// Back-compat alias for v5.x callers. `SimctlDriver` was renamed to
1012/// `IosDriver` in v6.0 c1a (cross-platform naming); this alias keeps
1013/// existing imports `use smix_driver::SimctlDriver` compiling.
1014pub type SimctlDriver = IosDriver;