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