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