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