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