smix_driver/lib.rs
1//! smix-driver — the decide layer.
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: front_app_of(&tree),
138 // `summary` stays empty by contract: the field docs say the
139 // caller writes it, and there is no single honest source
140 // for it here. The other two used to be empty for no
141 // reason at all.
142 summary: String::new(),
143 captured_at: captured_at_unix_millis(),
144 })
145 }
146
147 /// Resolve selector → single node (passthru-ish: driver.tree + resolver).
148 pub async fn find_one(
149 &self,
150 selector: &Selector,
151 include: Option<IncludeScope>,
152 ) -> Result<Option<A11yNode>, ExpectationFailure> {
153 let tree = self.tree_with_retry(include).await?;
154 Ok(resolve_selector(&tree, selector).cloned())
155 }
156
157 /// Resolve selector → its centroid as viewport-normalized
158 /// `(nx, ny)` in `[0, 1]`. `None` when selector resolves no node OR
159 /// the matched node has an empty / offscreen frame. Used by adapter
160 /// AnchorRelative dispatch to find an anchor's center before adding
161 /// a `(dx, dy)` shift.
162 pub async fn find_norm_coord(
163 &self,
164 selector: &Selector,
165 ) -> Result<Option<(f64, f64)>, ExpectationFailure> {
166 let tree = self.tree_with_retry(None).await?;
167 match resolve_to_norm_coord(&tree, selector) {
168 Ok((nx, ny)) => Ok(Some((nx, ny))),
169 Err(HostResolveError::NotFound | HostResolveError::EmptyMatchedFrame) => Ok(None),
170 Err(HostResolveError::UnknownAppFrame) => Err(ExpectationFailure::new(FailureInit {
171 code: Some(FailureCode::DriverError),
172 message: "find_norm_coord: tree bounds w/h ≤ 0 (unknown app frame)".into(),
173 ..Default::default()
174 })),
175 Err(HostResolveError::CentroidOutOfFrame { .. }) => Ok(None),
176 }
177 }
178
179 /// Resolve selector → all matching nodes.
180 pub async fn find_all(
181 &self,
182 selector: &Selector,
183 include: Option<IncludeScope>,
184 ) -> Result<Vec<A11yNode>, ExpectationFailure> {
185 let tree = self.tree_with_retry(include).await?;
186 Ok(resolve_selector_all(&tree, selector)
187 .into_iter()
188 .cloned()
189 .collect())
190 }
191
192 /// Boolean existence quick-probe.
193 ///
194 /// Selector-type dispatch:
195 /// - Text selectors with no spatial / index modifiers → runner
196 /// `/find` route (Apple element query, no full-tree snapshot —
197 /// the fast path).
198 /// - Id / Label / Role / Focused / Anchor selectors, and Text
199 /// with spatial (`below` / `near` / ...) or index (`nth` /
200 /// `first` / `last`) modifiers → host-resolve fallback:
201 /// `tree() + resolve_selector_all()` client-side. The runner
202 /// `/find` route only knows how to query by text predicate, so
203 /// anything richer has to ride on the full tree snapshot.
204 ///
205 /// Both dispatch branches poll within `TOTAL_TIMEOUT_MS` on
206 /// `/find` 500 or `/tree` 500 (sim still launching, runner
207 /// re-attach, etc.), matching the `wait_for` transient-retry
208 /// pattern.
209 pub async fn find(
210 &self,
211 selector: &Selector,
212 include: Option<IncludeScope>,
213 ) -> Result<bool, ExpectationFailure> {
214 if can_use_find_route(selector) {
215 let start = Instant::now();
216 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
217 let mut last_transport_err: Option<ExpectationFailure> = None;
218 loop {
219 // The live route asks for on-screen (frame ∩
220 // app frame), not bare existence. Bare `.exists` is
221 // true for below-the-fold elements, which made `find`
222 // (and everything built on it: runFlow.when gates,
223 // wait_for_not_visible, tapOn poll probes) disagree
224 // with tapOn's honest resolution on scrollable screens.
225 match self.runner.find_on_screen(selector, include).await {
226 Ok(present) => return Ok(present),
227 Err(e) => {
228 let failure = transport_to_failure(e);
229 if start.elapsed() >= timeout {
230 return Err(last_transport_err.unwrap_or(failure));
231 }
232 last_transport_err = Some(failure);
233 }
234 }
235 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
236 }
237 } else {
238 let tree = self.tree_with_retry(include).await?;
239 // Tree-resolve branch gains the same live
240 // on-screen confirmation as wait_for. See
241 // `confirm_on_screen` for semantics.
242 let matched = resolve_selector_all(&tree, selector);
243 if matched.is_empty() {
244 return Ok(false);
245 }
246 Ok(self.confirm_on_screen(&matched, include).await)
247 }
248 }
249
250 /// Live on-screen confirmation for tree-matched nodes.
251 ///
252 /// iOS 26.5 + RN 0.86 Fabric SNAPSHOT frames drift for
253 /// below-the-fold elements: the tree reports stale in-viewport
254 /// coords with `visible=true`, so the resolver's frame∩viewport
255 /// filter passes while the element is actually off screen. The
256 /// LIVE XCUI query re-resolves current layout and tells the
257 /// truth (the same reason `tapOn` fails honestly on such
258 /// elements).
259 ///
260 /// For up to the first 3 matched nodes that carry a
261 /// live-queryable handle (`identifier`, else `label` — the two
262 /// fields the runner `/find` predicate matches), ask the runner
263 /// whether an element with that handle is on screen right now.
264 /// Any confirmed node ⇒ true. Nodes with NO handle can't be
265 /// live-confirmed — if none of the matched nodes has a handle,
266 /// the tree verdict stands; OCR tiers remain the fallback for
267 /// handle-less degraded trees.
268 ///
269 /// Transport errors during confirmation also let the tree
270 /// verdict stand: a flaky live probe must not turn a legitimate
271 /// tree hit into a miss.
272 async fn confirm_on_screen(
273 &self,
274 matched: &[&A11yNode],
275 include: Option<IncludeScope>,
276 ) -> bool {
277 let mut had_handle = false;
278 for node in matched.iter().take(3) {
279 let handle = node
280 .identifier
281 .as_deref()
282 .filter(|s| !s.is_empty())
283 .or_else(|| node.label.as_deref().filter(|s| !s.is_empty()));
284 let Some(handle) = handle else { continue };
285 had_handle = true;
286 let probe = Selector::Text {
287 text: Pattern::Text(handle.to_string()),
288 modifiers: smix_selector::Modifiers::default(),
289 };
290 match self.runner.find_on_screen(&probe, include).await {
291 Ok(true) => return true,
292 Ok(false) => continue,
293 // Live probe unavailable → tree verdict stands.
294 Err(_) => return true,
295 }
296 }
297 // No node carried a live handle → cannot confirm → trust tree.
298 !had_handle
299 }
300
301 /// Transient `/tree` transport retry helper. Shared by
302 /// `find_one` / `find_all` / `find` (host-resolve branch). Mirrors
303 /// the transport-retry segment of `wait_for`: poll within
304 /// `TOTAL_TIMEOUT_MS` budget, sleep `POLL_INTERVAL_MS` between
305 /// attempts, surface only the last transport error on budget
306 /// exhaustion.
307 ///
308 /// Not folded into `wait_for` / `tap` because those loops have
309 /// additional in-loop logic (selector poll + ctx cache for
310 /// `wait_for`; `HostResolveError::NotFound` retry for `tap`) — DRY
311 /// here would harm clarity.
312 async fn tree_with_retry(
313 &self,
314 include: Option<IncludeScope>,
315 ) -> Result<A11yNode, ExpectationFailure> {
316 let start = Instant::now();
317 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
318 let mut last_transport_err: Option<ExpectationFailure> = None;
319 loop {
320 match self.tree(include).await {
321 Ok(tree) => return Ok(tree),
322 Err(e) => {
323 if start.elapsed() >= timeout {
324 return Err(last_transport_err.unwrap_or(e));
325 }
326 last_transport_err = Some(e);
327 }
328 }
329 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
330 }
331 }
332
333 /// `GET /system-popups` passthru.
334 pub async fn system_popups(
335 &self,
336 include: Option<IncludeScope>,
337 ) -> Result<Vec<SystemPopup>, ExpectationFailure> {
338 self.runner
339 .system_popups(include)
340 .await
341 .map_err(transport_to_failure)
342 }
343
344 /// `POST /system-popup-action` passthru.
345 /// Returns `Ok(true)` when the runner matched and tapped, `Ok(false)`
346 /// on 404 not_found. Transport errors map to `ExpectationFailure`.
347 pub async fn system_popup_action(
348 &self,
349 popup_id: &str,
350 button_id: &str,
351 ) -> Result<bool, ExpectationFailure> {
352 self.runner
353 .system_popup_action(popup_id, button_id)
354 .await
355 .map_err(transport_to_failure)
356 }
357
358 // ---- act -----------------------------------------------------------
359
360 /// Tap a selector. Host-side resolve → centroid →
361 /// `runner.tap_at_norm_coord` (Apple native event chain), with a
362 /// 5s implicit wait + retry loop.
363 ///
364 /// The resolve+centroid+normalize pipeline lives in the
365 /// [`smix_host_coord_resolver`] stone; this method orchestrates the
366 /// smix-specific 5s implicit-wait loop plus AI-readable failure
367 /// rendering + runner injection.
368 /// Tap a selector, and report what the touch landed on.
369 ///
370 /// Returns an outcome rather than unit: "the touch was synthesised"
371 /// and "the element was tapped" are different claims, and this used
372 /// to make the first while callers read the second.
373 pub async fn tap(
374 &self,
375 selector: &Selector,
376 include: Option<IncludeScope>,
377 ) -> Result<ActOutcome, ExpectationFailure> {
378 let start = Instant::now();
379 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
380
381 let (nx, ny, aimed) = loop {
382 // Transport retry parity with wait_for / find. Tree fetch
383 // transient transport drops (runner socket refusal /
384 // concurrent-handling hiccup) are re-tried in-loop.
385 let tree = self.tree_with_retry(include).await?;
386 match resolve_to_norm_coord(&tree, selector) {
387 // The matched node is kept, not just its centre: the
388 // runner reports what the tapped point turned out to be
389 // inside, and that is only worth anything next to what
390 // was aimed at.
391 Ok(coord) => {
392 let aimed = resolve_selector(&tree, selector).map(|n| HitElement {
393 identifier: n.identifier.clone().unwrap_or_default(),
394 label: n.label.clone().unwrap_or_default(),
395 frame: (n.bounds.x, n.bounds.y, n.bounds.w, n.bounds.h),
396 });
397 break (coord.0, coord.1, aimed);
398 }
399 Err(HostResolveError::NotFound) => {
400 if start.elapsed() > timeout {
401 let visible = collect_visible_summaries(&tree, 10);
402 let target = base_text_or_id(selector);
403 let suggestions =
404 smix_error::build_suggestions(target.as_deref(), &visible);
405 return Err(ExpectationFailure::new(FailureInit {
406 code: Some(FailureCode::ElementNotFound),
407 message: format!(
408 "element not found: {}",
409 describe_selector(selector)
410 ),
411 selector: Some(selector.clone()),
412 visible_elements: visible,
413 suggestions,
414 hint: Some(
415 "matched 0 nodes in the current a11y tree; check selector or wait for the screen to settle"
416 .into(),
417 ),
418 ..Default::default()
419 }));
420 }
421 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
422 continue;
423 }
424 Err(HostResolveError::EmptyMatchedFrame) => {
425 return Err(ExpectationFailure::new(FailureInit {
426 code: Some(FailureCode::ElementNotFound),
427 message: format!(
428 "matched node has empty/offscreen frame: {}",
429 describe_selector(selector)
430 ),
431 selector: Some(selector.clone()),
432 hint: Some(
433 "node bounds w*h == 0; element may be offscreen or hidden".into(),
434 ),
435 ..Default::default()
436 }));
437 }
438 Err(HostResolveError::UnknownAppFrame) => {
439 return Err(ExpectationFailure::new(FailureInit {
440 code: Some(FailureCode::DriverError),
441 message: format!(
442 "tree bounds w/h ≤ 0 — unknown app frame: {}",
443 describe_selector(selector)
444 ),
445 selector: Some(selector.clone()),
446 hint: Some(
447 "runner returned a tree with empty app frame; app may not be foregrounded"
448 .into(),
449 ),
450 ..Default::default()
451 }));
452 }
453 Err(HostResolveError::CentroidOutOfFrame { nx, ny }) => {
454 return Err(ExpectationFailure::new(FailureInit {
455 code: Some(FailureCode::ElementNotFound),
456 message: format!(
457 "matched node centroid out of app frame: {}",
458 describe_selector(selector)
459 ),
460 selector: Some(selector.clone()),
461 hint: Some(format!(
462 "centroid (nx={:.3}, ny={:.3}) outside (0,1); element offscreen",
463 nx, ny
464 )),
465 ..Default::default()
466 }));
467 }
468 }
469 };
470
471 let landed = self
472 .runner
473 .tap_at_norm_coord(nx, ny)
474 .await
475 .map_err(transport_to_failure)?;
476 let chain: Vec<HitElement> = landed
477 .chain
478 .iter()
479 .map(|e| HitElement {
480 identifier: e.identifier.clone(),
481 label: e.label.clone(),
482 frame: (e.frame.x, e.frame.y, e.frame.w, e.frame.h),
483 })
484 .collect();
485 let Some(aimed) = aimed else {
486 return Ok(ActOutcome {
487 target: None,
488 observed: chain,
489 verdict: ActVerdict::Unconfirmable(
490 "the selector resolved to a coordinate but not to a node, so \
491 there is nothing to compare the tapped point against"
492 .into(),
493 ),
494 });
495 };
496 // An empty chain is the one case that is NOT judged. A runner
497 // older than the field answers without it, and that is
498 // indistinguishable on the wire from a point that landed
499 // outside everything — failing both would break every flow
500 // driving an older runner.
501 let verdict = if chain.is_empty() {
502 ActVerdict::Unconfirmable(
503 "the runner reported no elements at the tapped point; it may \
504 predate the field that carries them"
505 .into(),
506 )
507 } else {
508 tap_landed_within(&aimed, &chain)
509 };
510 if let ActVerdict::Missed(why) = &verdict {
511 if tap_mismatch_is_fatal() {
512 return Err(ExpectationFailure::new(FailureInit {
513 code: Some(FailureCode::TapMissed),
514 message: format!("tap did not land where it aimed: {why}"),
515 selector: Some(selector.clone()),
516 hint: Some(
517 "the screen moved between the tree fetch and the tap; \
518 wait for it to settle first. Set \
519 SMIX_TAP_HIT_MISMATCH=warn to downgrade this to a \
520 warning while migrating a suite."
521 .into(),
522 ),
523 ..Default::default()
524 }));
525 }
526 eprintln!("smix: warning: tap did not land where it aimed: {why}");
527 }
528 Ok(ActOutcome {
529 target: Some(aimed),
530 observed: chain,
531 verdict,
532 })
533 }
534
535 /// Tap a selector several times in a row.
536 ///
537 /// Resolves once, then hands the runner a burst: one synthesise
538 /// carrying `times` touches spaced by `interval_ms`. The spacing is
539 /// the number given, not the round-trip latency it used to be —
540 /// ten separate taps cost ten ~400 ms synthesises, and a gesture
541 /// gated on a 500 ms window sat right on that boundary.
542 ///
543 /// No hit verdict: after the first touch the screen is expected to
544 /// react, so what the later ones land on is the app's business
545 /// rather than evidence about aim.
546 pub async fn tap_burst(
547 &self,
548 selector: &Selector,
549 times: u32,
550 interval_ms: Option<u32>,
551 hold_ms: Option<u32>,
552 include: Option<IncludeScope>,
553 ) -> Result<(), ExpectationFailure> {
554 let tree = self.tree_with_retry(include).await?;
555 let (nx, ny) = resolve_to_norm_coord(&tree, selector).map_err(|_| {
556 let visible = collect_visible_summaries(&tree, 10);
557 let target = base_text_or_id(selector);
558 let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
559 ExpectationFailure::new(FailureInit {
560 code: Some(FailureCode::ElementNotFound),
561 message: format!("element not found: {}", describe_selector(selector)),
562 selector: Some(selector.clone()),
563 visible_elements: visible,
564 suggestions,
565 ..Default::default()
566 })
567 })?;
568 self.runner
569 .tap_at_norm_coord_burst(nx, ny, times, interval_ms, hold_ms)
570 .await
571 .map(|_| ())
572 .map_err(transport_to_failure)
573 }
574
575 /// Tap a selector via an explicit dispatch mode. Used to opt into
576 /// the runner-side `daemonProxySynthesize` path for RN Pressable
577 /// buttons whose `RCTTouchHandler` gesture recognizer does not fire
578 /// `onPress` when the host-side `tap_at_norm_coord` Apple native
579 /// event chain is used.
580 ///
581 /// `TapMode::DaemonProxySynthesize` routes through `POST /tap`
582 /// (runner resolves selector + synthesizes via
583 /// `XCTRunnerDaemonSession.daemonProxy
584 /// ._XCT_synthesizeEvent:completion:`). The existing
585 /// [`SimctlDriver::tap`] method keeps the host-resolve +
586 /// `tap_at_norm_coord` default for callers that don't need the
587 /// daemonProxy alternative.
588 pub async fn tap_with_mode(
589 &self,
590 selector: &Selector,
591 mode: TapMode,
592 include: Option<IncludeScope>,
593 ) -> Result<(), ExpectationFailure> {
594 require_runner_resolvable_selector(selector, "/tap")?;
595 let start = Instant::now();
596 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
597
598 loop {
599 match self.runner.tap(selector, mode, include).await {
600 Ok(_result) => return Ok(()),
601 Err(e) => {
602 // 4xx is the runner refusing the request shape — it
603 // will refuse it identically on every retry, so the
604 // 5s budget bought nothing but latency.
605 let permanent = matches!(
606 &e,
607 smix_runner_client::RunnerTransportError::NonSuccessStatus {
608 status, ..
609 } if (400..500).contains(status) && *status != 404
610 );
611 if permanent || start.elapsed() > timeout {
612 return Err(transport_to_failure(e));
613 }
614 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
615 continue;
616 }
617 }
618 }
619 }
620
621 /// Double-tap a selector via swift sim-side XCUIElement.doubleTap().
622 /// 5s implicit-wait + retry on transport (mirrors
623 /// [`Self::tap_with_mode`]). Same as Maestro `doubleTapOn`.
624 pub async fn double_tap(
625 &self,
626 selector: &Selector,
627 include: Option<IncludeScope>,
628 ) -> Result<(), ExpectationFailure> {
629 require_runner_resolvable_selector(selector, "/double-tap")?;
630 let start = Instant::now();
631 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
632 loop {
633 match self.runner.double_tap(selector, include).await {
634 Ok(_result) => return Ok(()),
635 Err(e) => {
636 // 4xx is the runner refusing the request shape — it
637 // will refuse it identically on every retry, so the
638 // 5s budget bought nothing but latency.
639 let permanent = matches!(
640 &e,
641 smix_runner_client::RunnerTransportError::NonSuccessStatus {
642 status, ..
643 } if (400..500).contains(status) && *status != 404
644 );
645 if permanent || start.elapsed() > timeout {
646 return Err(transport_to_failure(e));
647 }
648 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
649 continue;
650 }
651 }
652 }
653 }
654
655 /// Long-press a selector for `duration` via swift sim-side
656 /// XCUIElement.press(forDuration:). 5s implicit-wait + retry on
657 /// transport. Same as Maestro `longPressOn`.
658 ///
659 /// Returns when the touch was held, anchored to this host's clock,
660 /// so a caller capturing frames alongside can tell whether they
661 /// fall inside the press. See [`press_frame_placement`].
662 pub async fn long_press(
663 &self,
664 selector: &Selector,
665 duration: Duration,
666 include: Option<IncludeScope>,
667 ) -> Result<PressTiming, ExpectationFailure> {
668 require_runner_resolvable_selector(selector, "/long-press")?;
669 let start = Instant::now();
670 let timeout = Duration::from_millis(TOTAL_TIMEOUT_MS);
671 let duration_ms = duration.as_millis().min(u64::MAX as u128) as u64;
672 loop {
673 let sent_ms = host_now_ms();
674 match self.runner.long_press(selector, duration_ms, include).await {
675 Ok(result) => {
676 return Ok(PressTiming {
677 sent_ms,
678 received_ms: host_now_ms(),
679 latest_down_offset_ms: result.latest_down_offset_ms,
680 earliest_up_offset_ms: result.earliest_up_offset_ms,
681 handler_wall_ms: result.handler_wall_ms,
682 });
683 }
684 Err(e) => {
685 // 4xx is the runner refusing the request shape — it
686 // will refuse it identically on every retry, so the
687 // 5s budget bought nothing but latency.
688 let permanent = matches!(
689 &e,
690 smix_runner_client::RunnerTransportError::NonSuccessStatus {
691 status, ..
692 } if (400..500).contains(status) && *status != 404
693 );
694 if permanent || start.elapsed() > timeout {
695 return Err(transport_to_failure(e));
696 }
697 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
698 continue;
699 }
700 }
701 }
702 }
703
704 /// Rotate sim via swift `XCUIDevice.shared.orientation`. Routes to
705 /// the runner-client `set_orientation` → POST /set-orientation.
706 pub async fn set_orientation(
707 &self,
708 orientation: Orientation,
709 ) -> Result<(), ExpectationFailure> {
710 self.runner
711 .set_orientation(orientation.as_wire())
712 .await
713 .map_err(transport_to_failure)?;
714 Ok(())
715 }
716
717 /// Fill text into the focused / matched input.
718 ///
719 /// **chunked-fill**: the swift runner's daemon `_XCT_sendString`
720 /// path bursts every keystroke at up to 200 chars/sec, which on
721 /// real-sim outraces React Native's `onChangeText` debounce on
722 /// the main thread — only the leading 2-3 keystrokes commit
723 /// before later ones are dropped by the JS thread reconciler.
724 /// The fix is host-side: split `text` into 1-char chunks and post
725 /// each to runner `/fill` separately, with an `INTER_CHAR_PAUSE_MS`
726 /// gap so the JS thread can flush its onChangeText callback before
727 /// the next keystroke fires.
728 ///
729 /// **selector-type dispatch** (mirrors `find()`): runner `/fill`
730 /// only accepts text selectors (`selector.text` field or the
731 /// `_focused_` magic). For Id / Label / Role / Anchor /
732 /// Text-with-modifiers selectors, host-resolve + tap the target
733 /// first to give it keyboard focus, then fill via the `_focused_`
734 /// magic. Text-without-modifiers selectors take the fast path
735 /// (direct chunked fill).
736 pub async fn fill(
737 &self,
738 selector: &Selector,
739 text: &str,
740 include: Option<IncludeScope>,
741 ) -> Result<(), ExpectationFailure> {
742 if can_use_find_route(selector) {
743 self.chunked_fill_runner(selector, text, include).await
744 } else if matches!(selector, Selector::Focused { .. }) {
745 // When the caller passes a Focused selector (e.g. via
746 // `Step::InputText` → `App::fill(&focused(), text)`), skip
747 // the pre-tap: `Focused` doesn't need a specific element,
748 // it's routing intent = "type into whatever is active,
749 // via key-event dispatch". The runner's `/fill` handler
750 // routes `_focused_` selector to daemon-level key event
751 // sending regardless of a11y-focus state — exactly the
752 // RN hidden-input case.
753 self.chunked_fill_runner(selector, text, include).await
754 } else {
755 self.tap(selector, include).await?;
756 sleep(Duration::from_millis(300)).await;
757 let focused = Selector::Focused {
758 focused: True(true),
759 };
760 self.chunked_fill_runner(&focused, text, include).await
761 }
762 }
763
764 async fn chunked_fill_runner(
765 &self,
766 selector: &Selector,
767 text: &str,
768 include: Option<IncludeScope>,
769 ) -> Result<(), ExpectationFailure> {
770 const INTER_CHAR_PAUSE_MS: u64 = 50;
771 let chars: Vec<char> = text.chars().collect();
772 if chars.len() <= 1 {
773 return self
774 .runner
775 .fill(selector, text, include)
776 .await
777 .map_err(transport_to_failure)
778 .map(|_| ());
779 }
780 for (i, ch) in chars.iter().enumerate() {
781 let chunk = ch.to_string();
782 self.runner
783 .fill(selector, &chunk, include)
784 .await
785 .map_err(transport_to_failure)?;
786 if i + 1 < chars.len() {
787 sleep(Duration::from_millis(INTER_CHAR_PAUSE_MS)).await;
788 }
789 }
790 Ok(())
791 }
792
793 /// Clear the focused / matched input.
794 ///
795 /// Selector-type dispatch (mirrors `fill()`).
796 ///
797 /// The runner `/clear` route only accepts text selectors (`selector.text`
798 /// field) or the `_focused_` magic. For Id / Label / Role / Anchor /
799 /// Text-with-modifiers selectors, host-resolve + tap the target first
800 /// so it owns keyboard focus, then clear via the `_focused_` magic
801 /// (single round-trip — clear is not chunked like fill).
802 pub async fn clear(
803 &self,
804 selector: &Selector,
805 include: Option<IncludeScope>,
806 ) -> Result<(), ExpectationFailure> {
807 if can_use_find_route(selector) {
808 self.runner
809 .clear(selector, include)
810 .await
811 .map_err(transport_to_failure)?;
812 } else {
813 self.tap(selector, include).await?;
814 sleep(Duration::from_millis(300)).await;
815 let focused = Selector::Focused {
816 focused: True(true),
817 };
818 self.runner
819 .clear(&focused, include)
820 .await
821 .map_err(transport_to_failure)?;
822 }
823 Ok(())
824 }
825
826 /// `POST /press-key` passthru.
827 pub async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure> {
828 self.runner
829 .press_key(key)
830 .await
831 .map_err(transport_to_failure)?;
832 Ok(())
833 }
834
835 /// Host-side scroll-until-visible loop. Alternates
836 /// `driver.tree + resolve_selector` (host-side probe) with
837 /// `runner.swipe_once` (single-swipe runner gesture). Up to 30
838 /// swipes or 20s timeout.
839 pub async fn scroll(
840 &self,
841 selector: &Selector,
842 direction: SwipeDirection,
843 ) -> Result<(), ExpectationFailure> {
844 let start = Instant::now();
845 let timeout = Duration::from_secs(20);
846 // Build the resolver cache once outside the swipe loop so regex
847 // compile cost is paid once, not per iteration. None case =
848 // regex compile error → element-not-found fail-fast
849 // (semantically equivalent to silent-None + 30-swipe timeout,
850 // but immediate).
851 let Some(ctx) = ResolverContext::new(selector) else {
852 return Err(ExpectationFailure::new(FailureInit {
853 code: Some(FailureCode::ElementNotFound),
854 message: format!(
855 "scroll({}, '{}'): selector pattern failed to compile",
856 describe_selector(selector),
857 direction
858 ),
859 selector: Some(selector.clone()),
860 hint: Some(
861 "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
862 .into(),
863 ),
864 ..Default::default()
865 }));
866 };
867 for i in 0..=SCROLL_MAX_SWIPES {
868 // Transport retry on tree fetch (see tap above).
869 let tree = self.tree_with_retry(None).await?;
870 if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
871 // Live on-screen confirmation. Without it a
872 // below-the-fold element with a drifted snapshot frame
873 // satisfies the probe on swipe 0 and scrollUntilVisible
874 // returns WITHOUT scrolling.
875 // A refuted confirm means "exists but not on screen
876 // yet" — exactly the state another swipe should fix.
877 let matched = [node];
878 if self.confirm_on_screen(&matched, None).await {
879 return Ok(());
880 }
881 }
882 if i == SCROLL_MAX_SWIPES || start.elapsed() > timeout {
883 let visible = collect_visible_summaries(&tree, 10);
884 let target = base_text_or_id(selector);
885 let suggestions = smix_error::build_suggestions(target.as_deref(), &visible);
886 return Err(ExpectationFailure::new(FailureInit {
887 code: Some(FailureCode::ElementNotFound),
888 message: format!(
889 "scroll({}, '{}'): element not visible after {} swipes",
890 describe_selector(selector),
891 direction,
892 SCROLL_MAX_SWIPES
893 ),
894 selector: Some(selector.clone()),
895 visible_elements: visible,
896 suggestions,
897 ..Default::default()
898 }));
899 }
900 self.runner
901 .swipe_once(direction)
902 .await
903 .map_err(transport_to_failure)?;
904 }
905 Ok(())
906 }
907
908 /// `POST /swipe-once` passthru.
909 pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure> {
910 self.runner
911 .swipe_once(direction)
912 .await
913 .map_err(transport_to_failure)?;
914 Ok(())
915 }
916
917 /// `POST /hide-keyboard` passthru.
918 pub async fn hide_keyboard(&self) -> Result<(), ExpectationFailure> {
919 self.runner
920 .hide_keyboard()
921 .await
922 .map_err(transport_to_failure)?;
923 Ok(())
924 }
925
926 /// `POST /back` passthru.
927 pub async fn back(&self) -> Result<(), ExpectationFailure> {
928 self.runner.back().await.map_err(transport_to_failure)?;
929 Ok(())
930 }
931
932 /// Tap at normalized (nx, ny) coordinates — escape hatch for
933 /// coord-based maestro yaml ports.
934 ///
935 /// (nx, ny) must be in [0, 1] (normalized to viewport). The runner
936 /// converts to device pixels via the Apple native event chain
937 /// (same path as the regular `tap()` centroid pipeline).
938 ///
939 /// **Escape hatch only — the selector path (`tap(&selector)`) is the
940 /// canonical surface.** This bypasses a11y resolve entirely.
941 pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure> {
942 self.runner
943 .tap_at_norm_coord(nx, ny)
944 .await
945 .map_err(transport_to_failure)?;
946 Ok(())
947 }
948
949 /// `POST /tap-by-id {id}` passthru — `XCUIElement.tap()` via the
950 /// XCTest gesture-recognizer chain. SwiftUI `.sheet` / `.alert` /
951 /// `.confirmationDialog` / `.fullScreenCover` dismiss buttons need
952 /// this path because the default host-HID-at-coord injects an
953 /// IOKit-level touch that doesn't fire the modal's SwiftUI binding
954 /// closure. Returns `ElementNotFound` when the runner reports
955 /// `ok=false`.
956 pub async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure> {
957 let ok = self
958 .runner
959 .tap_by_id(id)
960 .await
961 .map_err(transport_to_failure)?;
962 if !ok {
963 return Err(ExpectationFailure::new(FailureInit {
964 code: Some(FailureCode::ElementNotFound),
965 message: format!("tap_by_id: element not found — id=\"{id}\""),
966 hint: Some(
967 "runner XCUIQuery returned no match; check id spelling or wait for screen to settle"
968 .into(),
969 ),
970 ..Default::default()
971 }));
972 }
973 Ok(())
974 }
975
976 /// Eval JS via the app-side WKWebView bridge. Direct HTTP POST to
977 /// `127.0.0.1:28080/eval` (iOS sim shares host loopback) — does
978 /// NOT use the XCUITest runner. Returns the parsed JS result as a
979 /// JSON Value; surfaces bridge error / transport failure as
980 /// DriverError.
981 pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure> {
982 self.runner.webview_eval(js).await.map_err(|e| {
983 ExpectationFailure::new(FailureInit {
984 code: Some(FailureCode::DriverError),
985 message: format!("webview_eval: {e}"),
986 ..Default::default()
987 })
988 })
989 }
990
991 /// `POST /find-text-by-ocr` passthru — Apple Vision OCR over the
992 /// current XCUIScreen screenshot. Returns the matching text
993 /// observation's bounding box (UIKit normalized) or `None`.
994 pub async fn find_text_by_ocr(
995 &self,
996 text: &str,
997 locales: &[String],
998 recognition_level: &str,
999 ) -> Result<Option<OcrFrame>, ExpectationFailure> {
1000 self.runner
1001 .find_text_by_ocr(text, locales, recognition_level)
1002 .await
1003 .map_err(transport_to_failure)
1004 }
1005
1006 /// `POST /swipe-at-norm-coord {from, to}` passthru — escape hatch
1007 /// from-to swipe gesture sibling to [`Self::tap_at_norm_coord`].
1008 /// `from` / `to` are normalized to viewport `(0, 1)`. Escape-hatch
1009 /// companion to `tap_at_coord`.
1010 pub async fn swipe_at_norm_coord(
1011 &self,
1012 from: (f64, f64),
1013 to: (f64, f64),
1014 ) -> Result<(), ExpectationFailure> {
1015 self.runner
1016 .swipe_at_norm_coord(from, to)
1017 .await
1018 .map_err(transport_to_failure)?;
1019 Ok(())
1020 }
1021
1022 /// `POST /foreground {bundleId}` passthru.
1023 pub async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure> {
1024 self.runner
1025 .foreground(bundle_id)
1026 .await
1027 .map_err(transport_to_failure)?;
1028 Ok(())
1029 }
1030
1031 // ---- wait ----------------------------------------------------------
1032
1033 /// Poll until the selector resolves to a node, or timeout fires.
1034 /// Returns the matched node (cloned). 5s default budget, 250 ms
1035 /// poll interval.
1036 pub async fn wait_for(
1037 &self,
1038 selector: &Selector,
1039 timeout: Duration,
1040 include: Option<IncludeScope>,
1041 ) -> Result<A11yNode, ExpectationFailure> {
1042 let start = Instant::now();
1043 // Transient tree() transport errors (e.g. sim still launching →
1044 // /tree returns 500 snapshot_unavailable) are treated the same
1045 // as selector-not-found — retry within the timeout budget,
1046 // surface only the last error on timeout. This matches the
1047 // semantic intent of wait_for ("wait until ready or timeout")
1048 // and is required for callers that ride out sim boot transients.
1049 //
1050 // Build the resolver cache once outside the poll loop. None
1051 // case = regex compile error → Timeout fail with an explicit
1052 // compile-error hint (semantically equivalent to silent-None
1053 // + budget exhaustion, but immediate and actionable).
1054 let Some(ctx) = ResolverContext::new(selector) else {
1055 return Err(ExpectationFailure::new(FailureInit {
1056 code: Some(FailureCode::Timeout),
1057 message: format!(
1058 "waitFor({}): selector pattern failed to compile",
1059 describe_selector(selector)
1060 ),
1061 selector: Some(selector.clone()),
1062 hint: Some(
1063 "regex Pattern compile error — check selector syntax (unbalanced bracket / invalid escape / etc.)"
1064 .into(),
1065 ),
1066 ..Default::default()
1067 }));
1068 };
1069 let mut last_transport_err: Option<ExpectationFailure> = None;
1070 // Tracks "the tree matched but the live on-screen
1071 // check refuted it" across poll iterations so the timeout
1072 // failure can say WHY the wait never greened (below-the-fold
1073 // element with a drifted snapshot frame, which produces a
1074 // wait-pass → tap-miss pair).
1075 let mut tree_hit_offscreen = false;
1076 loop {
1077 match self.tree(include).await {
1078 Ok(tree) => {
1079 if let Some(node) = resolve_selector_compiled(&tree, selector, &ctx) {
1080 // Live on-screen confirmation.
1081 // Snapshot frames drift under iOS 26.5 + RN
1082 // Fabric; the resolver's frame∩viewport filter
1083 // can pass an element that is actually below
1084 // the fold. One live probe per tree hit keeps
1085 // wait_for / scrollUntilVisible / tapOn in
1086 // agreement on "visible".
1087 let matched = [node];
1088 if self.confirm_on_screen(&matched, include).await {
1089 return Ok(node.clone());
1090 }
1091 tree_hit_offscreen = true;
1092 }
1093 if start.elapsed() >= timeout {
1094 let visible = collect_visible_summaries(&tree, 10);
1095 let target = base_text_or_id(selector);
1096 let suggestions =
1097 smix_error::build_suggestions(target.as_deref(), &visible);
1098 let hint = if tree_hit_offscreen {
1099 Some(
1100 "the a11y tree matched this selector but the LIVE \
1101 on-screen check refuted it every time — the element \
1102 exists with a stale/drifted snapshot frame (typically \
1103 below the fold on iOS 26.5 + RN Fabric). Use \
1104 scrollUntilVisible to bring it into the viewport \
1105 first, or an ocrText tier to assert by pixels."
1106 .to_string(),
1107 )
1108 } else {
1109 None
1110 };
1111 return Err(ExpectationFailure::new(FailureInit {
1112 code: Some(FailureCode::Timeout),
1113 message: format!(
1114 "waitFor({}) timed out after {:?}",
1115 describe_selector(selector),
1116 timeout
1117 ),
1118 selector: Some(selector.clone()),
1119 visible_elements: visible,
1120 suggestions,
1121 hint,
1122 ..Default::default()
1123 }));
1124 }
1125 last_transport_err = None;
1126 }
1127 Err(e) => {
1128 if start.elapsed() >= timeout {
1129 return Err(last_transport_err.unwrap_or(e));
1130 }
1131 last_transport_err = Some(e);
1132 }
1133 }
1134 sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
1135 }
1136 }
1137
1138 // ---- lifecycle -----------------------------------------------------
1139
1140 /// Idempotent close hook. The current implementation has no
1141 /// sidecar / no cache to tear down; reserved for future use.
1142 pub async fn dispose(&self) -> Result<(), ExpectationFailure> {
1143 Ok(())
1144 }
1145}
1146
1147/// What an act did, as opposed to what it attempted.
1148///
1149/// The act surface returned `Result<(), ExpectationFailure>` — success
1150/// was the absence of an error — so nothing could report where a touch
1151/// actually went. `POST /tap` had carried a rich result all along and
1152/// the driver discarded it at three call sites; the default path never
1153/// asked at all.
1154///
1155/// `observed` travels even when `verdict` is `Confirmed`, because the
1156/// verdict cannot see occlusion and the chain can: a caller looking at
1157/// a scrim next to their button knows something the verdict does not.
1158#[derive(Clone, Debug, PartialEq)]
1159pub struct ActOutcome {
1160 /// The element the selector resolved to, when it resolved to one.
1161 pub target: Option<HitElement>,
1162 /// Named elements containing the point, innermost first.
1163 pub observed: Vec<HitElement>,
1164 /// Whether the act landed where it aimed.
1165 pub verdict: ActVerdict,
1166}
1167
1168impl ActOutcome {
1169 /// An act that reached the device with nothing to say about aim.
1170 ///
1171 /// Used by paths that dispatch without resolving a target — a raw
1172 /// coordinate tap has no element to have missed.
1173 #[must_use]
1174 pub fn unjudged() -> Self {
1175 ActOutcome {
1176 target: None,
1177 observed: Vec::new(),
1178 verdict: ActVerdict::Unconfirmable(
1179 "this path dispatches without resolving a target element".into(),
1180 ),
1181 }
1182 }
1183}
1184
1185/// Does a tap that landed outside its target fail the step?
1186///
1187/// Yes, by default: reporting success for a touch that went somewhere
1188/// else is the thing this check exists to stop. `SMIX_TAP_HIT_MISMATCH
1189/// =warn` downgrades it, so a suite written before the check can be
1190/// moved over a flow at a time rather than all at once.
1191///
1192/// Deliberately not the other way round. Shipping it as a warning and
1193/// promising to make it fail later hands the value of the change to the
1194/// next release, and the release after that inherits a suite that has
1195/// been green through every miss.
1196fn tap_mismatch_is_fatal() -> bool {
1197 !std::env::var("SMIX_TAP_HIT_MISMATCH")
1198 .map(|v| v.eq_ignore_ascii_case("warn"))
1199 .unwrap_or(false)
1200}
1201
1202/// One element, as either side describes it.
1203///
1204/// Three fields and not a whole `A11yNode`: the question is "is this
1205/// the thing I aimed at", and every extra field is another way two
1206/// truthful descriptions can differ for reasons that are not the
1207/// answer.
1208#[derive(Clone, Debug, PartialEq)]
1209pub struct HitElement {
1210 /// Accessibility identifier, empty when the element has none.
1211 pub identifier: String,
1212 /// Accessibility label, empty when the element has none.
1213 pub label: String,
1214 /// `(x, y, w, h)` in the app's coordinate space.
1215 pub frame: (f64, f64, f64, f64),
1216}
1217
1218/// What an act turned out to have done.
1219#[derive(Clone, Debug, PartialEq)]
1220pub enum ActVerdict {
1221 /// The touch landed inside the element that was aimed at.
1222 Confirmed,
1223 /// It landed somewhere else, or on nothing.
1224 Missed(String),
1225 /// Nothing comparable came back.
1226 ///
1227 /// Its own verdict rather than a pass, because "I could not tell"
1228 /// and "it landed" are different facts and only one of them is
1229 /// what `tapOn` claims.
1230 Unconfirmable(String),
1231}
1232
1233/// Tolerance, in points, for comparing frames.
1234///
1235/// A frame makes a round trip — the host normalises the centre against
1236/// the app frame, the runner multiplies it back — so exact equality
1237/// would fail on arithmetic rather than on aim.
1238const FRAME_TOLERANCE_PT: f64 = 1.0;
1239
1240/// Wall clock in milliseconds, for anchoring a press window and the
1241/// captures taken alongside it to the same timeline.
1242#[must_use]
1243pub fn host_now_ms() -> u64 {
1244 std::time::SystemTime::now()
1245 .duration_since(std::time::UNIX_EPOCH)
1246 .map_or(0, |d| d.as_millis() as u64)
1247}
1248
1249/// What the runner reported about when a press was actually held,
1250/// paired with what the host observed about the round trip.
1251///
1252/// Offsets are measured by the runner from the moment its handler was
1253/// entered, not from any shared clock. Nothing here assumes the
1254/// simulator and the host agree on the time of day.
1255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1256pub struct PressTiming {
1257 /// Host clock when the request went out.
1258 pub sent_ms: u64,
1259 /// Host clock when the response came back.
1260 pub received_ms: u64,
1261 /// Runner clock, handler entry → the latest instant the touch could
1262 /// have gone down.
1263 ///
1264 /// The runner cannot see inside `press(forDuration:)`, so it reports
1265 /// bounds rather than instants: the call spanned `[A, B]` and held
1266 /// for `d`, so the touch went down no later than `B - d` and lifted
1267 /// no earlier than `A + d`. Composing these as if they were the
1268 /// instants themselves would widen the window rather than narrow it,
1269 /// which is why they are named for the bound they are.
1270 pub latest_down_offset_ms: u64,
1271 /// Runner clock, handler entry → the earliest instant the touch
1272 /// could have lifted.
1273 pub earliest_up_offset_ms: u64,
1274 /// Runner clock, handler entry → handler return.
1275 pub handler_wall_ms: u64,
1276}
1277
1278/// When a captured frame's pixels were sampled, as an interval.
1279///
1280/// A screenshot is not instantaneous — around 230ms on an M-series
1281/// simulator — and nothing says which instant inside that the pixels
1282/// come from. So a capture is an interval, and only a capture whose
1283/// whole interval sits inside the press is during the press.
1284#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1285pub struct CaptureSpan {
1286 /// Host clock when the capture was started.
1287 pub start_ms: u64,
1288 /// Host clock when the capture returned.
1289 pub end_ms: u64,
1290}
1291
1292/// Where a captured frame sits relative to the press.
1293#[derive(Clone, Debug, PartialEq)]
1294pub enum FramePlacement {
1295 /// The touch was provably still down for the whole capture.
1296 DuringPress,
1297 /// The touch was provably not down for part of the capture.
1298 Outside(String),
1299 /// It cannot be placed either way.
1300 ///
1301 /// Its own answer rather than a pass, because the whole point of
1302 /// the verb is to hand back a frame of a held state, and a frame
1303 /// that might be of a resting one is what sent the consumer who
1304 /// asked for this down a wrong path in the first place.
1305 Uncertain(String),
1306}
1307
1308impl PressTiming {
1309 /// How much of the round trip is unaccounted for by the handler,
1310 /// and therefore could have been spent in either direction.
1311 fn transit_ambiguity_ms(&self) -> u64 {
1312 self.received_ms
1313 .saturating_sub(self.sent_ms)
1314 .saturating_sub(self.handler_wall_ms)
1315 }
1316
1317 /// The interval over which the touch is certainly down, on the host
1318 /// clock.
1319 ///
1320 /// The handler was entered somewhere in `[sent, sent +
1321 /// ambiguity]`, so touch-down is no later than `sent + ambiguity +
1322 /// down_offset` and lift-up is no earlier than `sent + up_offset`.
1323 /// Those two bounds are the interval that holds under every
1324 /// division of the round trip.
1325 fn certainly_held_ms(&self) -> Option<(u64, u64)> {
1326 let start = self.sent_ms + self.transit_ambiguity_ms() + self.latest_down_offset_ms;
1327 let end = self.sent_ms + self.earliest_up_offset_ms;
1328 (start < end).then_some((start, end))
1329 }
1330}
1331
1332impl PressTiming {
1333 /// A press whose bounds the runner did not report.
1334 ///
1335 /// Every offset is zero, so no interval is certainly held and every
1336 /// frame comes back `Uncertain`. That is the honest answer for a
1337 /// platform that cannot time its own press.
1338 #[must_use]
1339 pub fn unplaceable() -> Self {
1340 PressTiming {
1341 sent_ms: 0,
1342 received_ms: 0,
1343 latest_down_offset_ms: 0,
1344 earliest_up_offset_ms: 0,
1345 handler_wall_ms: 0,
1346 }
1347 }
1348}
1349
1350/// Was this frame captured while the touch was down?
1351///
1352/// Judged against the interval that holds under every division of the
1353/// round trip between request transit, handler work, and response
1354/// transit — so a `DuringPress` needs no assumption about which of
1355/// those the unaccounted milliseconds went to, and no assumption that
1356/// the simulator's clock agrees with the host's.
1357#[must_use]
1358pub fn press_frame_placement(press: &PressTiming, frame: &CaptureSpan) -> FramePlacement {
1359 let held_ms = press
1360 .earliest_up_offset_ms
1361 .saturating_sub(press.latest_down_offset_ms);
1362 let ambiguity = press.transit_ambiguity_ms();
1363 let Some((held_from, held_to)) = press.certainly_held_ms() else {
1364 return FramePlacement::Uncertain(format!(
1365 "the press was held for {held_ms}ms but {ambiguity}ms of the \
1366 round trip is unaccounted for, so no instant on this host's \
1367 clock is certainly inside it — hold for longer than {ambiguity}ms"
1368 ));
1369 };
1370 if frame.start_ms >= held_from && frame.end_ms <= held_to {
1371 return FramePlacement::DuringPress;
1372 }
1373 if frame.start_ms >= held_to {
1374 return FramePlacement::Outside(format!(
1375 "the capture started {}ms after the touch could still have been \
1376 down — the press was {held_ms}ms and a capture takes around \
1377 230ms, so it has to start earlier or the press has to be longer",
1378 frame.start_ms - held_to
1379 ));
1380 }
1381 if frame.end_ms <= held_from {
1382 return FramePlacement::Outside(format!(
1383 "the capture finished {}ms before the touch was certainly down",
1384 held_from - frame.end_ms
1385 ));
1386 }
1387 FramePlacement::Uncertain(format!(
1388 "the capture ran {}..{} and the touch was certainly down only over \
1389 {held_from}..{held_to}, so its pixels could be from either side of \
1390 the boundary",
1391 frame.start_ms, frame.end_ms
1392 ))
1393}
1394
1395/// Did the touch land inside the element it aimed at?
1396///
1397/// `chain` is every named element containing the tapped point, as the
1398/// runner found them after synthesising the touch.
1399///
1400/// # Why containment and not identity
1401///
1402/// The first version of this asked whether the element at the point
1403/// *was* the element aimed at. A live tree says why that is wrong. At
1404/// the centre of the first row of Settings, the named elements
1405/// containing the point are:
1406///
1407/// ```text
1408/// staticText "登录以访问iCloud数据…" area 7283
1409/// button id=com.apple.settings.primaryAppleAccount area 33423
1410/// application id=com.apple.Preferences area 351348
1411/// ```
1412///
1413/// A flow aiming at that button taps its centre, and the innermost
1414/// element there is the button's own label. Identity would call a
1415/// perfectly good tap a miss — and text nested inside a row is what
1416/// every list screen looks like. Containment gets it right: the button
1417/// is on the chain.
1418///
1419/// # WHAT THIS CANNOT SEE
1420///
1421/// **Occlusion.** A scrim covering the aimed element contains the
1422/// point too, so this passes. The snapshot the runner walks carries no
1423/// z-order (`TreeRoute.swift`: snapshots are dead frames), and
1424/// `isHittable` — Apple's own answer — has been rejected here twice
1425/// on purpose: it reports false for an element that is reachable in
1426/// the AX tree but visually covered, which is exactly the see-through
1427/// tap `SmixRunnerUITests.swift` performs deliberately, and it broke a
1428/// QA-overlay assertion in v1.0.27.
1429///
1430/// So this closes the stale-frame half of "the tap reported success and
1431/// nothing happened" and not the covered-element half. The whole chain
1432/// travels in the outcome regardless, so a caller can see the scrim
1433/// even when the verdict passes.
1434pub fn tap_landed_within(aimed: &HitElement, chain: &[HitElement]) -> ActVerdict {
1435 if chain.is_empty() {
1436 return ActVerdict::Missed(format!(
1437 "aimed at {} and the tapped point held nothing — the element \
1438 moved between the tree fetch and the tap, or its frame was \
1439 stale",
1440 describe_hit(aimed)
1441 ));
1442 }
1443 if chain.iter().any(|c| same_element(aimed, c)) {
1444 return ActVerdict::Confirmed;
1445 }
1446 if aimed.identifier.is_empty() && aimed.label.is_empty() {
1447 return ActVerdict::Unconfirmable(format!(
1448 "the element aimed at carries neither an identifier nor a \
1449 label, so it cannot be looked for among the {} element(s) \
1450 at the tapped point",
1451 chain.len()
1452 ));
1453 }
1454 ActVerdict::Missed(format!(
1455 "aimed at {} but the tapped point is inside {} instead",
1456 describe_hit(aimed),
1457 chain
1458 .iter()
1459 .map(describe_hit)
1460 .collect::<Vec<_>>()
1461 .join(", ")
1462 ))
1463}
1464
1465/// Are these two descriptions the same element?
1466///
1467/// By the strongest field both carry: identifier, then label, then
1468/// geometry.
1469fn same_element(a: &HitElement, b: &HitElement) -> bool {
1470 if !a.identifier.is_empty() && !b.identifier.is_empty() {
1471 return a.identifier == b.identifier;
1472 }
1473 if !a.label.is_empty() && !b.label.is_empty() {
1474 return a.label == b.label;
1475 }
1476 if a.identifier.is_empty()
1477 && a.label.is_empty()
1478 && b.identifier.is_empty()
1479 && b.label.is_empty()
1480 {
1481 let close = |x: f64, y: f64| (x - y).abs() <= FRAME_TOLERANCE_PT;
1482 return close(a.frame.0, b.frame.0)
1483 && close(a.frame.1, b.frame.1)
1484 && close(a.frame.2, b.frame.2)
1485 && close(a.frame.3, b.frame.3);
1486 }
1487 // One is named and the other is not: they are describable in
1488 // different vocabularies, which is not evidence of sameness.
1489 false
1490}
1491
1492fn describe_hit(e: &HitElement) -> String {
1493 if !e.identifier.is_empty() {
1494 format!("id={}", e.identifier)
1495 } else if !e.label.is_empty() {
1496 format!("label={:?}", e.label)
1497 } else {
1498 format!(
1499 "an unnamed element at ({:.0},{:.0} {:.0}x{:.0})",
1500 e.frame.0, e.frame.1, e.frame.2, e.frame.3
1501 )
1502 }
1503}
1504
1505/// The /tap, /double-tap and /long-press routes decode ONLY a plain
1506/// literal `selector.text` — the Swift side has no resolver for id /
1507/// label / role / regex forms, and silently drops modifiers. Reject
1508/// those here, before the wire: they used to go out anyway, 400, and
1509/// then burn the full 5s transport-retry budget before surfacing an
1510/// unrelated-looking error (or, for a modifier, tap the wrong element).
1511/// Forms the runner-side routes can resolve on their own.
1512///
1513/// Named for what it admits rather than what it refuses: the old name
1514/// said "plain text" while the set was always narrower than that
1515/// phrase and is now wider. text / id / label are exactly what the
1516/// runner's NSPredicate expresses directly.
1517///
1518/// Everything else stays host-side deliberately. Regex needs the
1519/// pattern semantics, roles need the rawType→Role table, and spatial
1520/// or index modifiers need the tree walk — putting any of them behind
1521/// this wire would mean one contract with two implementations, one of
1522/// them inside XCUITest where it cannot be tested the same way.
1523///
1524/// Public so a gate can ask this rule directly instead of restating
1525/// it. A restatement would drift the first time the set widened, which
1526/// is how the actions guide came to document a pairing this had always
1527/// refused. Not part of the supported surface — hidden from the docs
1528/// and free to change with the routes it guards.
1529#[doc(hidden)]
1530pub fn require_runner_resolvable_selector(
1531 selector: &Selector,
1532 route: &str,
1533) -> Result<(), ExpectationFailure> {
1534 let default_modifiers = smix_selector::Modifiers::default();
1535 let ok = match selector {
1536 Selector::Text { text, modifiers } => {
1537 matches!(text, smix_selector::Pattern::Text(_)) && *modifiers == default_modifiers
1538 }
1539 Selector::Id { modifiers, .. } | Selector::Label { modifiers, .. } => {
1540 *modifiers == default_modifiers
1541 }
1542 _ => false,
1543 };
1544 if ok {
1545 return Ok(());
1546 }
1547 Err(ExpectationFailure::new(FailureInit {
1548 code: Some(FailureCode::DriverError),
1549 message: format!(
1550 "{route} resolves text, id and label selectors runner-side; it \
1551 does not take regex patterns, roles, or spatial/index modifiers. \
1552 Those resolve against the full tree, which only the host has — \
1553 use the default tap (host-side resolve) for them."
1554 ),
1555 ..Default::default()
1556 }))
1557}
1558
1559fn transport_to_failure(e: RunnerTransportError) -> ExpectationFailure {
1560 let (code, hint) = match &e {
1561 RunnerTransportError::Unreachable { .. } => (
1562 FailureCode::DriverError,
1563 Some("start the runner first: bash scripts/smix-runner-health.sh".to_string()),
1564 ),
1565 // The runner can't snapshot the target app, either because
1566 // it's not foreground or because XCUITest bound to a different
1567 // bundle. Emit an AI-readable hint that names the resolution
1568 // channel.
1569 RunnerTransportError::AppUnavailable { target, reason, .. } => (
1570 FailureCode::DriverError,
1571 Some(format!(
1572 "runner reports snapshot_unavailable — target={} reason={}. \
1573 Fix by (a) `smix run --bundle-id <BUNDLE>` so the client sends \
1574 App-Bundle-Id header, or (b) `smix run --activate` so the runner \
1575 auto-activates the target before snapshot, or (c) foreground the \
1576 target app before invocation.",
1577 target.as_deref().unwrap_or("<unknown>"),
1578 reason.as_deref().unwrap_or("<no reason>"),
1579 )),
1580 ),
1581 _ => (FailureCode::DriverError, None),
1582 };
1583 ExpectationFailure::new(FailureInit {
1584 code: Some(code),
1585 message: format!("{e}"),
1586 hint,
1587 ..Default::default()
1588 })
1589}
1590
1591/// Extract the base text or id pattern from a Selector for suggestion
1592/// generation. Returns the literal string of the base form's pattern, or
1593/// None for selectors whose base doesn't have a literal target (anchor,
1594/// role-only, focused).
1595fn base_text_or_id(selector: &Selector) -> Option<String> {
1596 match selector {
1597 Selector::Text { text, .. } => match text {
1598 Pattern::Text(s) => Some(s.clone()),
1599 Pattern::Regex { regex, .. } => Some(regex.clone()),
1600 },
1601 Selector::Id { id, .. } => Some(id.clone()),
1602 Selector::Label { label, .. } => Some(label.clone()),
1603 Selector::Role { name, .. } => name.as_ref().map(|p| match p {
1604 Pattern::Text(s) => s.clone(),
1605 Pattern::Regex { regex, .. } => regex.clone(),
1606 }),
1607 Selector::Focused { .. } | Selector::Anchor { .. } => None,
1608 // LocalizedText should be desugared to Selector::Text at the
1609 // adapter layer before reaching the driver. Return None here
1610 // as the suggestion-hint fallback.
1611 Selector::LocalizedText { localized_text, .. } => {
1612 // Best-effort: return the "en" entry as a hint for AI-readable
1613 // suggestion output if available; else first table entry.
1614 localized_text
1615 .get("en")
1616 .or_else(|| localized_text.values().next())
1617 .cloned()
1618 }
1619 // The OcrText adapter dispatches OCR + tap_at_coord directly
1620 // and does not go through the driver host-resolve path.
1621 // base_text_or_id still returns ocr_text as an AI-readable
1622 // suggestion hint (e.g. for fallback-chain error reports).
1623 Selector::OcrText { ocr_text, .. } => Some(ocr_text.clone()),
1624 // AnchorRelative is an escape hatch family; the
1625 // adapter dispatches directly. Recurse into the anchor
1626 // sub-selector for the hint.
1627 Selector::AnchorRelative { anchor, .. } => base_text_or_id(anchor),
1628 // Point has no element text; report None (the suggestion hint
1629 // will look elsewhere).
1630 Selector::Point { .. } => None,
1631 Selector::Fallback { fallback } => fallback.first().and_then(base_text_or_id),
1632 }
1633}
1634
1635// Only Text selectors with no spatial / index modifiers ride the runner
1636// `/find` route. Everything else (Id / Label / Role / Focused / Anchor,
1637// or Text augmented with below / near / nth / first / last / ancestor /
1638// ...) falls back to host-resolve in `find()` above. The ancestor
1639// modifier is included here — Apple element query has no parent-chain
1640// semantic, so ancestor-bearing selectors must host-resolve.
1641fn can_use_find_route(selector: &Selector) -> bool {
1642 let Selector::Text { text, modifiers } = selector else {
1643 return false;
1644 };
1645 // Regex patterns serialize as `{"regex": …, "flags": …}`
1646 // objects, which the runner /find route's decode (expecting
1647 // `selector.text` as a plain string) rejects with 400. A regex
1648 // Text selector dispatched here would therefore burn the full
1649 // transport-retry budget (~8 s) and surface a DriverError instead
1650 // of evaluating. Only literal patterns ride the live route; regex
1651 // falls back to host-resolve like every other complex shape.
1652 if !matches!(text, Pattern::Text(_)) {
1653 return false;
1654 }
1655 modifiers.near.is_none()
1656 && modifiers.below.is_none()
1657 && modifiers.above.is_none()
1658 && modifiers.left_of.is_none()
1659 && modifiers.right_of.is_none()
1660 && modifiers.inside.is_none()
1661 && modifiers.ancestor.is_none()
1662 && modifiers.nth.is_none()
1663 && modifiers.first.is_none()
1664 && modifiers.last.is_none()
1665}
1666
1667// Silence unused-import warning for symbols re-exported as future hooks.
1668// `Modifiers` is used by external callers constructing selectors via
1669// the smix-selector re-export; we surface it here for SDK convenience.
1670#[doc(hidden)]
1671pub use smix_selector::Modifiers as _ModifiersReexport;
1672
1673// match_text_compiled is exported through smix-selector; surface here
1674// for downstream test / sdk convenience.
1675#[doc(hidden)]
1676pub use smix_selector::match_text_compiled as _match_text_compiled_reexport;
1677#[allow(dead_code)]
1678fn _silence_unused_imports() {
1679 // Touch symbols so unused-import warnings don't trip.
1680 let _: fn(&A11yNode, &smix_selector::CompiledPattern) -> bool = match_text_compiled;
1681 let _: Modifiers = Modifiers::default();
1682 let _: ScreenDescription = ScreenDescription::default();
1683 let _ = summarize_node;
1684}
1685
1686// ===========================================================================
1687// Cross-platform Driver trait + Android-ready architecture
1688// ===========================================================================
1689
1690mod android;
1691mod ios;
1692mod traits;
1693
1694pub use android::AndroidDriver;
1695pub use traits::{Driver, Platform};
1696
1697/// The bundle id this description was taken from, read off the a11y
1698/// tree's root identifier.
1699///
1700/// The runner writes the app it resolved into that identifier, so the
1701/// value is already on the wire; nothing new has to be fetched. An
1702/// empty identifier becomes `None` rather than `Some("")` — an empty
1703/// string is "I don't know" wearing the costume of "I know", and this
1704/// field was previously an unconditional empty string for exactly that
1705/// reason.
1706#[must_use]
1707pub fn front_app_of(tree: &A11yNode) -> Option<String> {
1708 tree.identifier
1709 .as_deref()
1710 .map(str::trim)
1711 .filter(|s| !s.is_empty())
1712 .map(str::to_string)
1713}
1714
1715/// Wall-clock milliseconds at capture. Milliseconds, matching the
1716/// field's documented unit — a seconds clock here would be wrong by
1717/// three orders of magnitude and still look plausible.
1718fn captured_at_unix_millis() -> f64 {
1719 std::time::SystemTime::now()
1720 .duration_since(std::time::UNIX_EPOCH)
1721 .map(|d| d.as_millis() as f64)
1722 .unwrap_or(0.0)
1723}
1724
1725/// Back-compat alias. `SimctlDriver` was renamed to `IosDriver` for
1726/// cross-platform naming; this alias keeps existing imports
1727/// `use smix_driver::SimctlDriver` compiling.
1728pub type SimctlDriver = IosDriver;
1729
1730#[cfg(test)]
1731mod runner_resolvable_tests {
1732 use super::*;
1733 use smix_selector::{Modifiers, Pattern, Selector};
1734
1735 /// The three forms a runner-side route can match directly.
1736 ///
1737 /// The guard used to accept only plain text, which is why
1738 /// `dispatch: daemonProxy` could never address an RN testID — the
1739 /// one thing that escape hatch exists for. The actions guide has
1740 /// documented that pairing since it was written.
1741 #[test]
1742 fn runner_resolvable_accepts_plain_text() {
1743 let sel = Selector::Text {
1744 text: Pattern::Text("Sign In".into()),
1745 modifiers: Modifiers::default(),
1746 };
1747 assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
1748 }
1749
1750 #[test]
1751 fn runner_resolvable_accepts_id() {
1752 let sel = Selector::Id {
1753 id: "btn-login".into(),
1754 modifiers: Modifiers::default(),
1755 };
1756 assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
1757 }
1758
1759 #[test]
1760 fn runner_resolvable_accepts_label() {
1761 let sel = Selector::Label {
1762 label: "Sign In".into(),
1763 modifiers: Modifiers::default(),
1764 };
1765 assert!(require_runner_resolvable_selector(&sel, "/tap").is_ok());
1766 }
1767
1768 /// Regex needs the host's pattern semantics. Accepting it here
1769 /// would mean a second implementation inside XCUITest.
1770 #[test]
1771 fn runner_resolvable_rejects_regex_text() {
1772 let sel = Selector::Text {
1773 text: Pattern::Regex {
1774 regex: "^Sign".into(),
1775 flags: "i".into(),
1776 },
1777 modifiers: Modifiers::default(),
1778 };
1779 assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
1780 }
1781
1782 /// Roles need the rawType→Role table, which lives host-side.
1783 #[test]
1784 fn runner_resolvable_rejects_role() {
1785 let sel = Selector::Role {
1786 role: smix_selector::Role::Button,
1787 name: None,
1788 modifiers: Modifiers::default(),
1789 };
1790 assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
1791 }
1792
1793 /// Modifiers need the whole tree walk; an accepted form with a
1794 /// modifier attached would silently drop the modifier.
1795 #[test]
1796 fn runner_resolvable_rejects_index_modifier() {
1797 let sel = Selector::Id {
1798 id: "row".into(),
1799 modifiers: Modifiers {
1800 nth: Some(2),
1801 ..Modifiers::default()
1802 },
1803 };
1804 assert!(require_runner_resolvable_selector(&sel, "/tap").is_err());
1805 }
1806}
1807
1808#[cfg(test)]
1809mod describe_meta_tests {
1810 use super::*;
1811 use smix_screen::A11yNode;
1812
1813 fn node_with_identifier(id: Option<&str>) -> A11yNode {
1814 A11yNode {
1815 raw_type: "application".into(),
1816 element_type_raw: 1,
1817 role: None,
1818 identifier: id.map(str::to_string),
1819 label: None,
1820 title: None,
1821 placeholder_value: None,
1822 value: None,
1823 text: None,
1824 bounds: smix_screen::Rect {
1825 x: 0.0,
1826 y: 0.0,
1827 w: 390.0,
1828 h: 844.0,
1829 },
1830 enabled: true,
1831 selected: false,
1832 has_focus: false,
1833 visible: true,
1834 children: vec![],
1835 }
1836 }
1837
1838 /// The bundle id has been on the wire all along: the runner sets it
1839 /// as the tree root's identifier so host-side smoke can assert on
1840 /// it. The ledger recorded this field as having no honest source
1841 /// outside the runner, which was wrong.
1842 #[test]
1843 fn describe_meta_front_app_reads_tree_root_identifier() {
1844 let tree = node_with_identifier(Some("com.apple.Preferences"));
1845 assert_eq!(
1846 front_app_of(&tree).as_deref(),
1847 Some("com.apple.Preferences")
1848 );
1849 }
1850
1851 /// None, not "". An empty string is "I don't know" wearing the
1852 /// costume of "I know" — the exact confusion this segment exists to
1853 /// remove.
1854 #[test]
1855 fn describe_meta_front_app_is_none_without_root_identifier() {
1856 assert_eq!(front_app_of(&node_with_identifier(None)), None);
1857 assert_eq!(front_app_of(&node_with_identifier(Some(""))), None);
1858 }
1859
1860 #[test]
1861 fn describe_meta_captured_at_is_unix_millis() {
1862 // 2026-01-01T00:00:00Z in ms. A seconds-based clock would be
1863 // ~1000x smaller and fail here rather than silently mislabel.
1864 assert!(captured_at_unix_millis() > 1_767_225_600_000.0);
1865 }
1866
1867 /// describe() does not own `summary`; the field docs say the caller
1868 /// fills it. Pinned so "it's empty" reads as the contract rather
1869 /// than as the same omission the other two fields had.
1870 #[test]
1871 fn describe_meta_summary_is_not_produced_here() {
1872 assert_eq!(smix_screen::ScreenDescription::default().summary, "");
1873 }
1874}