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