Skip to main content

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}