Skip to main content

smix_runner_client/
lib.rs

1//! smix-runner-client — HTTP IPC client to `swift-bridge/SmixRunnerCore`
2//! (outer crate). v3.1 c8, **first outer crate** allowing reqwest +
3//! tokio + thiserror + tracing deps per user 2026-05-25 brief.
4//!
5//! Ported from now-retired TS source: `src/sim/runner-client.ts` (1129 lines). Wire-level
6//! 1:1 compatibility with the Swift side — any route shape / query param
7//! / response body shape change must be done in lockstep with
8//! `swift-bridge/Sources/SmixRunnerCore`.
9//!
10//! # Routes (18 endpoints, port progress c8 = MVP set)
11//!
12//! - `GET /health` — connectivity probe (memoized via [`HttpRunnerClient::ensure_reachable`])
13//! - `GET /tree?include=` — full a11y tree dump (returns [`A11yNode`])
14//! - `GET /system-popups?include=` — list of [`SystemPopup`]
15//! - `POST /tap {selector, mode, include?}` — selector → element tap
16//! - `POST /tap-at-norm-coord {nx, ny}` — Apple native event chain coord tap
17//! - `POST /find {selector, include?}` — boolean existence check (v1.4 quick-probe)
18//! - `POST /fill {selector, text, include?}` — fill text into input
19//! - `POST /clear {selector, include?}` — clear input
20//! - `POST /press-key {key}` — press named key (Return/Tab/etc.)
21//! - `POST /scroll {selector, direction, include?}` — scroll-until-visible
22//! - `POST /swipe-once {direction}` — single swipe gesture (no probe loop)
23//! - `POST /foreground {bundleId}` — bring app to foreground
24//! - `POST /hide-keyboard` — swipe-down to dismiss keyboard
25//! - `POST /back` — back gesture
26//! - `POST /record/start` — begin recording AX notifications
27//! - `GET /record/poll` — drain recorded events
28//! - `POST /record/stop` — stop recording, drain final events
29//!
30//! Per CLAUDE.md §9 #4 — never expose raw URL / sleep / xpath surfaces.
31//! All operations go through the typed methods below.
32
33#![doc(html_root_url = "https://docs.smix.dev/smix-runner-client")]
34
35use serde::{Deserialize, Serialize};
36use smix_input::{KeyName, SwipeDirection};
37use smix_screen::{A11yNode, derive_roles_recursive};
38use smix_selector::Selector;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::Duration;
42use thiserror::Error;
43
44// -------------------- Error types ----------------------------------------
45
46/// Transport-level failure (network, non-2xx, malformed body). Mirrors
47/// TS `RunnerTransportError` 1:1.
48#[derive(Debug, Error)]
49pub enum RunnerTransportError {
50    #[error("runner {endpoint} fetch failed: {source}")]
51    FetchFailed {
52        endpoint: String,
53        #[source]
54        source: reqwest::Error,
55    },
56    #[error("runner {endpoint} returned status {status}: {body}")]
57    NonSuccessStatus {
58        endpoint: String,
59        status: u16,
60        body: String,
61    },
62    #[error("runner {endpoint} returned non-JSON body: {source}")]
63    NonJsonBody {
64        endpoint: String,
65        #[source]
66        source: reqwest::Error,
67    },
68    #[error("runner {endpoint} returned malformed body: {detail}")]
69    MalformedBody { endpoint: String, detail: String },
70    #[error("runner {endpoint} unreachable: {message}")]
71    Unreachable { endpoint: String, message: String },
72    /// v0.2.1 — gol-611-v0.2.1 §Phase B. The runner returned
73    /// `{"ok":false,"error":"snapshot_unavailable"}` (or the enriched
74    /// v0.2.1 body with `target`/`reason` fields). This is not a network
75    /// failure — it means XCUITest could not snapshot the app the client
76    /// asked about. Callers that see this after all retries can surface
77    /// an AI-readable hint about foreground state.
78    #[error(
79        "runner {endpoint}: app unavailable (target={target:?}, reason={reason:?})"
80    )]
81    AppUnavailable {
82        endpoint: String,
83        target: Option<String>,
84        reason: Option<String>,
85    },
86}
87
88/// v0.2.1 — per-request context carried across the wire via the
89/// `App-Bundle-Id` and `App-Activate` HTTP headers. Client sets
90/// these via `HttpRunnerClient::with_target_bundle_id` /
91/// `with_auto_activate`, or per-call by cloning the client with a
92/// modified context. The runner side reads them into
93/// `SmixRunnerServer.currentContext` (task-local) and passes to
94/// `resolveApp()` in every app-touching handler.
95///
96/// The two-field split is intentional: `bundle_id` is "which app do I
97/// want to talk to", `activate` is "should we bring it foreground first".
98/// Insight's PoC repro (`Preferences briefly foregrounded → snapshot_
99/// unavailable forever`) is solved by the first; the second is a
100/// resilience add-on for cases where the target might not be frontmost.
101#[derive(Clone, Debug, Default)]
102pub struct RequestContext {
103    pub bundle_id: Option<String>,
104    pub activate: bool,
105}
106
107/// `POST /tap` returned `404 / not_found` — element matched no node.
108/// Mirrors TS `TapNotFoundError`.
109#[derive(Debug, Error)]
110#[error("tap not_found for selector {selector_json}: {body}")]
111pub struct TapNotFoundError {
112    pub selector_json: String,
113    pub body: String,
114}
115
116/// `POST /scroll` returned `200 / matched:false / swipes:N` — scroll
117/// exhausted N swipes without surfacing the target. Mirrors TS
118/// `RunnerScrollNotMatched`.
119#[derive(Debug, Error)]
120#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
121pub struct RunnerScrollNotMatched {
122    pub selector_json: String,
123    pub swipes: u32,
124}
125
126/// `POST /swipe-once` returned `200 / ok:false / vanished_during_swipe`.
127/// Mirrors TS `RunnerSwipeOnceFailure`.
128#[derive(Debug, Error)]
129#[error("swipeOnce failed: {detail}")]
130pub struct RunnerSwipeOnceFailure {
131    pub detail: String,
132}
133
134/// v5.19 c1 — normalized OCR bounding box returned by
135/// [`HttpRunnerClient::find_text_by_ocr`]. Coordinates are normalized to
136/// `[0, 1]` in UIKit coord space (top-left origin, y-down). Apple Vision's
137/// native bbox is bottom-left origin + y-up — the swift handler converts
138/// before returning so all consumers see UIKit coords.
139#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
140pub struct OcrFrame {
141    /// Top-left x in `[0, 1]`.
142    pub nx: f64,
143    /// Top-left y in `[0, 1]`.
144    pub ny: f64,
145    /// Width in `[0, 1]`.
146    pub w: f64,
147    /// Height in `[0, 1]`.
148    pub h: f64,
149}
150
151impl OcrFrame {
152    /// Center x in `[0, 1]`.
153    #[must_use]
154    pub fn mid_x(&self) -> f64 {
155        self.nx + self.w * 0.5
156    }
157    /// Center y in `[0, 1]`.
158    #[must_use]
159    pub fn mid_y(&self) -> f64 {
160        self.ny + self.h * 0.5
161    }
162}
163
164// -------------------- Wire types ----------------------------------------
165//
166// v3.2 c6 — wire types now live in the smix-runner-wire stone. The HTTP
167// client (this crate) re-exports them so existing consumers don't break.
168// Anyone needing the wire shapes without the reqwest+tokio HTTP impl
169// should depend on smix-runner-wire directly.
170
171pub use smix_runner_wire::{
172    FindRequest, FindResponse, IncludeScope, KeyboardStages, RecordEventsResponse, RecordedEvent,
173    RunnerIncludeOpts, RunnerKeyboardResult, RunnerScrollSelector, ScrollResponse, SystemPopup,
174    SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
175    TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
176};
177
178// -------------------- Transport retry constants -------------------------
179
180/// Total transport attempts (1 initial + 2 retries) for transient reqwest
181/// connection errors. See `send_with_retry` doc.
182const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
183/// Backoff between transport retry attempts.
184const TRANSPORT_BACKOFF_MS: u64 = 100;
185
186/// v0.2.1 — classify a non-2xx runner response body. When the body
187/// carries `snapshot_unavailable` (v0.2.0 shape) or the enriched
188/// `{"ok":false,"error":"snapshot_unavailable","target":"...","reason":"..."}`
189/// (v0.2.1+ shape), lift it to a first-class
190/// `RunnerTransportError::AppUnavailable` variant. Otherwise fall
191/// through to the generic `NonSuccessStatus`.
192fn classify_error_body(endpoint: &str, status: u16, body: &str) -> RunnerTransportError {
193    if body.contains("snapshot_unavailable") {
194        // Parse enriched body when present. Any parse miss falls back to
195        // just the marker string — still surfaces useful hint upstream.
196        #[derive(Deserialize)]
197        struct Unavailable {
198            #[serde(default)]
199            target: Option<String>,
200            #[serde(default)]
201            reason: Option<String>,
202        }
203        let parsed: Option<Unavailable> = serde_json::from_str(body).ok();
204        return RunnerTransportError::AppUnavailable {
205            endpoint: endpoint.to_string(),
206            target: parsed.as_ref().and_then(|p| p.target.clone()),
207            reason: parsed.as_ref().and_then(|p| p.reason.clone()),
208        };
209    }
210    RunnerTransportError::NonSuccessStatus {
211        endpoint: endpoint.to_string(),
212        status,
213        body: body.chars().take(200).collect(),
214    }
215}
216
217// -------------------- Client --------------------------------------------
218
219/// HTTP client to the swift-side SmixRunnerCore. Async (tokio-based).
220/// Constructed once per Cell; `ensure_reachable` memoizes a successful
221/// `/health` probe so subsequent calls don't re-probe.
222#[derive(Debug)]
223pub struct HttpRunnerClient {
224    base: String,
225    client: reqwest::Client,
226    reachable: Arc<AtomicBool>,
227    /// v0.2.1 — target bundle id, sent as `App-Bundle-Id` header on
228    /// every request. `None` = client doesn't say; runner uses its
229    /// boot-time default `app`.
230    target_bundle_id: Option<String>,
231    /// v0.2.1 — if `true`, sends `App-Activate: true` on every request
232    /// so the runner calls `.activate()` on the resolved target before
233    /// operating. Insight's suggestion §2 — auto-recovers from cases
234    /// where XCUITest's implicit app-under-test binds to the wrong
235    /// foreground.
236    auto_activate: bool,
237}
238
239impl HttpRunnerClient {
240    /// Construct a client targeting `http://127.0.0.1:{port}`.
241    pub fn new(port: u16) -> Self {
242        Self::with_base(format!("http://127.0.0.1:{port}"))
243    }
244
245    /// Construct with an explicit base URL (test / non-localhost cases).
246    pub fn with_base<S: Into<String>>(base: S) -> Self {
247        let client = reqwest::Client::builder()
248            .timeout(Duration::from_secs(15))
249            .build()
250            .expect("reqwest::Client::builder default never fails");
251        HttpRunnerClient {
252            base: base.into(),
253            client,
254            reachable: Arc::new(AtomicBool::new(false)),
255            target_bundle_id: None,
256            auto_activate: false,
257        }
258    }
259
260    /// v0.2.1 — set the target bundle id sent as `App-Bundle-Id`
261    /// header on every subsequent request. Runner rebinds
262    /// `XCUIApplication(bundleIdentifier:)` per request if this differs
263    /// from its boot-time default. gol-611-v0.2.1 §Phase B.
264    #[must_use]
265    pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
266        self.target_bundle_id = Some(bundle.into());
267        self
268    }
269
270    /// v0.2.1 — mutable variant of [`with_target_bundle_id`]. Used from
271    /// the driver-trait pass-through where the driver holds the client
272    /// by value and can't easily use the builder shape.
273    pub fn set_target_bundle_id<S: Into<String>>(&mut self, bundle: S) {
274        self.target_bundle_id = Some(bundle.into());
275    }
276
277    /// v0.2.1 — when set, every subsequent request sends
278    /// `App-Activate: true`, causing the runner to `.activate()` the
279    /// resolved target before operating. gol-611-v0.2.1 §Phase B.
280    #[must_use]
281    pub fn with_auto_activate(mut self, activate: bool) -> Self {
282        self.auto_activate = activate;
283        self
284    }
285
286    /// v0.2.1 — mutable variant of [`with_auto_activate`].
287    pub fn set_auto_activate(&mut self, activate: bool) {
288        self.auto_activate = activate;
289    }
290
291    /// v0.2.1 — accessor for the current target bundle. Used by callers
292    /// that want to log or diagnose.
293    pub fn target_bundle_id(&self) -> Option<&str> {
294        self.target_bundle_id.as_deref()
295    }
296
297    /// v0.2.1 — accessor for the current auto-activate flag.
298    pub fn auto_activate(&self) -> bool {
299        self.auto_activate
300    }
301
302    /// v0.2.1 — apply per-request context headers to a reqwest builder.
303    /// Every method that constructs a request calls this so the
304    /// `App-Bundle-Id` / `App-Activate` headers are sent uniformly.
305    fn apply_context(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
306        let mut b = builder;
307        if let Some(bundle) = &self.target_bundle_id {
308            b = b.header("App-Bundle-Id", bundle);
309        }
310        if self.auto_activate {
311            b = b.header("App-Activate", "true");
312        }
313        b
314    }
315
316    /// Base URL accessor.
317    pub fn base(&self) -> &str {
318        &self.base
319    }
320
321    // ---- low-level helpers ----
322
323    fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
324        match include {
325            Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
326            None => format!("{}{}", self.base, endpoint),
327        }
328    }
329
330    /// v4.1 c5 — wire-level transport retry for transient reqwest connection
331    /// errors (`is_connect()` / `is_request()` — pre-server-receipt failures
332    /// raised before any bytes leave the local socket, idempotent-safe for
333    /// every route). Server-side `5xx` / `NonJsonBody` are NOT retried here:
334    /// those are semantic responses, retried (or not) by callers like
335    /// `driver.find`'s 500 retry loop or `driver.wait_for`'s selector poll.
336    ///
337    /// Budget: 3 attempts total (1 initial + 2 retries) × `TRANSPORT_BACKOFF_MS`
338    /// between attempts. Empirically catches the v4.1 c4 baseline regression
339    /// signature (sim/runner socket hiccup mid-flow) without inflating happy-
340    /// path latency: retries only fire on actual failure.
341    async fn send_with_retry<F>(
342        &self,
343        endpoint: &str,
344        builder_fn: F,
345    ) -> Result<reqwest::Response, RunnerTransportError>
346    where
347        F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
348    {
349        let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
350        loop {
351            attempts_left -= 1;
352            match builder_fn(&self.client).send().await {
353                Ok(res) => return Ok(res),
354                Err(e) => {
355                    let retryable = e.is_connect() || e.is_request();
356                    if !retryable || attempts_left == 0 {
357                        return Err(RunnerTransportError::FetchFailed {
358                            endpoint: endpoint.to_string(),
359                            source: e,
360                        });
361                    }
362                    tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
363                }
364            }
365        }
366    }
367
368    async fn json_get<T: for<'de> Deserialize<'de>>(
369        &self,
370        endpoint: &str,
371        include: Option<IncludeScope>,
372    ) -> Result<T, RunnerTransportError> {
373        let url = self.url(endpoint, include);
374        let res = self
375            .send_with_retry(endpoint, |c| self.apply_context(c.get(&url)))
376            .await?;
377        let status = res.status();
378        if !status.is_success() {
379            let body = res.text().await.unwrap_or_default();
380            return Err(classify_error_body(endpoint, status.as_u16(), &body));
381        }
382        res.json::<T>()
383            .await
384            .map_err(|source| RunnerTransportError::NonJsonBody {
385                endpoint: endpoint.to_string(),
386                source,
387            })
388    }
389
390    async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
391        &self,
392        endpoint: &str,
393        body: &B,
394        include: Option<IncludeScope>,
395    ) -> Result<T, RunnerTransportError> {
396        let url = self.url(endpoint, include);
397        let res = self
398            .send_with_retry(endpoint, |c| self.apply_context(c.post(&url).json(body)))
399            .await?;
400        let status = res.status();
401        if !status.is_success() {
402            let body = res.text().await.unwrap_or_default();
403            return Err(classify_error_body(endpoint, status.as_u16(), &body));
404        }
405        res.json::<T>()
406            .await
407            .map_err(|source| RunnerTransportError::NonJsonBody {
408                endpoint: endpoint.to_string(),
409                source,
410            })
411    }
412
413    // ---- public methods (mirrors TS RunnerClient surface) ----
414
415    /// `GET /health` — bare alive probe (no memoization). Returns true on 2xx.
416    pub async fn health(&self) -> bool {
417        let url = self.url("/health", None);
418        match self.client.get(&url).send().await {
419            Ok(res) => res.status().is_success(),
420            Err(_) => false,
421        }
422    }
423
424    /// `GET /health` memoized — first call probes; subsequent are no-ops
425    /// after one success. Failure raises [`RunnerTransportError::Unreachable`].
426    /// Mirrors TS `ensureReachable`.
427    pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
428        if self.reachable.load(Ordering::Acquire) {
429            return Ok(());
430        }
431        if self.health().await {
432            self.reachable.store(true, Ordering::Release);
433            return Ok(());
434        }
435        Err(RunnerTransportError::Unreachable {
436            endpoint: "/health".into(),
437            message: format!("SmixRunner not reachable at {}", self.base),
438        })
439    }
440
441    /// `GET /tree?include=` — full a11y tree dump.
442    ///
443    /// v3.5 c3: post-deser pass [`derive_roles_recursive`] fills
444    /// `A11yNode.role` from `raw_type` because the Swift `/tree` route
445    /// only emits `rawType` on the wire. Without this, `Selector::Role`
446    /// would never match real-sim payloads.
447    pub async fn get_tree(
448        &self,
449        include: Option<IncludeScope>,
450    ) -> Result<A11yNode, RunnerTransportError> {
451        let mut tree: A11yNode = self.json_get("/tree", include).await?;
452        derive_roles_recursive(&mut tree);
453        Ok(tree)
454    }
455
456    /// `GET /system-popups?include=` — list system popups.
457    pub async fn system_popups(
458        &self,
459        include: Option<IncludeScope>,
460    ) -> Result<Vec<SystemPopup>, RunnerTransportError> {
461        #[derive(Deserialize)]
462        struct Envelope {
463            popups: Vec<SystemPopup>,
464        }
465        let env: Envelope = self.json_get("/system-popups", include).await?;
466        Ok(env.popups)
467    }
468
469    /// `POST /system-popup-action` (v4.2 c2 — G9 act side). Tap a button
470    /// on a previously enumerated popup. `popup_id` and `button_id` are
471    /// the `Popup.id` / `PopupButton.id` strings returned by
472    /// [`Self::system_popups`]; the runner walks the same scan order so
473    /// the round-trip does not need a host-side id map.
474    ///
475    /// Returns `Ok(true)` when the runner matched both ids and dispatched
476    /// the tap, `Ok(false)` when the runner returned a 404 not_found
477    /// (popup id, button id, or synthesize dispatch missed). Transport
478    /// errors and non-2xx-non-404 statuses surface as
479    /// [`RunnerTransportError`].
480    pub async fn system_popup_action(
481        &self,
482        popup_id: &str,
483        button_id: &str,
484    ) -> Result<bool, RunnerTransportError> {
485        let url = self.url("/system-popup-action", None);
486        let body = SystemPopupActionRequest {
487            popup_id: popup_id.to_string(),
488            button_id: button_id.to_string(),
489        };
490        let res = self
491            .send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
492            .await?;
493        let status = res.status();
494        if status.as_u16() == 404 {
495            return Ok(false);
496        }
497        if !status.is_success() {
498            let body = res.text().await.unwrap_or_default();
499            return Err(RunnerTransportError::NonSuccessStatus {
500                endpoint: "/system-popup-action".to_string(),
501                status: status.as_u16(),
502                body: body.chars().take(200).collect(),
503            });
504        }
505        let resp: SystemPopupActionResponse =
506            res.json()
507                .await
508                .map_err(|source| RunnerTransportError::NonJsonBody {
509                    endpoint: "/system-popup-action".to_string(),
510                    source,
511                })?;
512        Ok(resp.ok)
513    }
514
515    /// `POST /tap` — selector → element tap. Errors:
516    /// - 404 → [`TapNotFoundError`] returned via `Err(.. Other ..)`. (TS
517    ///   throws subtype; here we surface via the dedicated variant of a
518    ///   future TapError type — for c8 MVP we keep the simple
519    ///   `RunnerTransportError + body inspection` pattern. Driver layer
520    ///   wraps with ExpectationFailure.)
521    pub async fn tap(
522        &self,
523        selector: &Selector,
524        mode: TapMode,
525        include: Option<IncludeScope>,
526    ) -> Result<TapResult, RunnerTransportError> {
527        #[derive(Serialize)]
528        struct Req<'a> {
529            selector: &'a Selector,
530            mode: TapMode,
531        }
532        self.json_post("/tap", &Req { selector, mode }, include)
533            .await
534    }
535
536    /// `POST /tap-at-norm-coord` (v1.6 c5) — Apple native UI event coord tap.
537    pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), RunnerTransportError> {
538        #[derive(Serialize)]
539        struct Req {
540            nx: f64,
541            ny: f64,
542        }
543        let _: serde_json::Value = self
544            .json_post("/tap-at-norm-coord", &Req { nx, ny }, None)
545            .await?;
546        Ok(())
547    }
548
549    /// `POST /double-tap-at-norm-coord` (v6.0 c3c-v) — double-tap at
550    /// viewport-normalized coord. Android-specific endpoint backing
551    /// `AndroidDriver::double_tap` after host-resolve. iOS path uses
552    /// selector-based `/double-tap`; this exists for Android where the
553    /// runner does its own host-resolve via tree dump.
554    pub async fn double_tap_at_norm_coord(
555        &self,
556        nx: f64,
557        ny: f64,
558    ) -> Result<(), RunnerTransportError> {
559        #[derive(Serialize)]
560        struct Req {
561            nx: f64,
562            ny: f64,
563        }
564        let _: serde_json::Value = self
565            .json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
566            .await?;
567        Ok(())
568    }
569
570    /// `POST /long-press-at-norm-coord` (v6.0 c3c-v) — long-press at coord
571    /// with explicit duration. Android-specific.
572    pub async fn long_press_at_norm_coord(
573        &self,
574        nx: f64,
575        ny: f64,
576        duration_ms: u64,
577    ) -> Result<(), RunnerTransportError> {
578        #[derive(Serialize)]
579        struct Req {
580            nx: f64,
581            ny: f64,
582            #[serde(rename = "durationMs")]
583            duration_ms: u64,
584        }
585        let _: serde_json::Value = self
586            .json_post(
587                "/long-press-at-norm-coord",
588                &Req {
589                    nx,
590                    ny,
591                    duration_ms,
592                },
593                None,
594            )
595            .await?;
596        Ok(())
597    }
598
599    /// `POST /input-text` (v6.0 c3c-v) — type text into currently-focused
600    /// input. Caller must tap to focus the field first (AndroidDriver
601    /// orchestrates). Android-specific.
602    pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
603        #[derive(Serialize)]
604        struct Req<'a> {
605            text: &'a str,
606        }
607        let _: serde_json::Value = self.json_post("/input-text", &Req { text }, None).await?;
608        Ok(())
609    }
610
611    /// `POST /tap-by-id` (v5.3 c4) — `XCUIElement.tap()` via the XCTest
612    /// gesture-recognizer chain. Drives SwiftUI `.sheet` / `.alert` /
613    /// `.confirmationDialog` / `.fullScreenCover` dismiss bindings that the
614    /// default host-HID-at-coord path can't trigger. Returns
615    /// `Ok(true)` when the element was found and tapped, `Ok(false)` when
616    /// the runner reported `ok=false` (element not found / NSException
617    /// surfaced through `smixGuarded`). Parallel to
618    /// `tap_at_norm_coord`; the existing `/tap` envelope stays untouched.
619    pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
620        #[derive(Serialize)]
621        struct Req<'a> {
622            id: &'a str,
623        }
624        #[derive(Deserialize)]
625        struct Resp {
626            ok: bool,
627        }
628        let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
629        Ok(resp.ok)
630    }
631
632    /// `POST /find-text-by-ocr` (v5.19 c1) — Apple Vision OCR over the
633    /// current XCUIScreen screenshot. Returns the matching text
634    /// observation's bounding box normalized to `[0, 1]` in UIKit coord
635    /// space (top-left origin, y-down). `Ok(None)` when no observation
636    /// matches the keyword. L5 sense layer for the a11y-less + i18n
637    /// initiative; covers ~40-50% of "lib without testID but with
638    /// visible text" scenarios per master plan §1.
639    ///
640    /// `locales` are BCP-47 language subtags (e.g. `["en"]` / `["ja"]`);
641    /// empty defaults to `["en"]` on the swift side. `recognition_level`
642    /// is `"accurate"` (default, slower + higher accuracy) or `"fast"`.
643    pub async fn find_text_by_ocr(
644        &self,
645        text: &str,
646        locales: &[String],
647        recognition_level: &str,
648    ) -> Result<Option<OcrFrame>, RunnerTransportError> {
649        #[derive(Serialize)]
650        struct Req<'a> {
651            text: &'a str,
652            locales: &'a [String],
653            recognition_level: &'a str,
654        }
655        #[derive(Deserialize)]
656        struct Resp {
657            found: bool,
658            frame: Option<[f64; 4]>,
659        }
660        let resp: Resp = self
661            .json_post(
662                "/find-text-by-ocr",
663                &Req {
664                    text,
665                    locales,
666                    recognition_level,
667                },
668                None,
669            )
670            .await?;
671        if !resp.found {
672            return Ok(None);
673        }
674        match resp.frame {
675            Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
676            None => Ok(None),
677        }
678    }
679
680    /// v5.21 c1b — Eval JS against fixture-side WKWebView bridge (Option
681    /// A per a11y-i18n master plan §1 L5+). Does NOT use the XCUITest
682    /// runner — POSTs directly to the app-side debug bridge on
683    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback). Returns
684    /// the JS eval result as JSON Value or runtime error from the bridge.
685    ///
686    /// **Scope**: works for any app that exposes the
687    /// `SmixWebViewBridge` (selftest-fixture has it by default; user apps
688    /// must opt in). Bridge port is fixed at 28080 today.
689    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
690        #[derive(Serialize)]
691        struct Req<'a> {
692            js: &'a str,
693        }
694        #[derive(Deserialize)]
695        struct Resp {
696            result: serde_json::Value,
697            error: String,
698        }
699        let url = "http://127.0.0.1:28080/eval";
700        let resp = reqwest::Client::new()
701            .post(url)
702            .json(&Req { js })
703            .send()
704            .await
705            .map_err(|e| RunnerTransportError::Unreachable {
706                endpoint: "webview-bridge".into(),
707                message: format!("webview-bridge POST: {e}"),
708            })?;
709        let status = resp.status();
710        let body: Resp = resp
711            .json()
712            .await
713            .map_err(|e| RunnerTransportError::Unreachable {
714                endpoint: "webview-bridge".into(),
715                message: format!("webview-bridge JSON decode (status {status}): {e}"),
716            })?;
717        if !body.error.is_empty() {
718            return Err(RunnerTransportError::Unreachable {
719                endpoint: "webview-bridge".into(),
720                message: format!("webview-bridge JS error: {}", body.error),
721            });
722        }
723        Ok(body.result)
724    }
725
726    /// `POST /double-tap` (v5.2 c3) — XCUIElement.doubleTap() public API.
727    /// Mirrors `/tap` envelope but issues two-tap gesture on the resolved
728    /// element. Maestro `doubleTapOn` 同源.
729    pub async fn double_tap(
730        &self,
731        selector: &Selector,
732        include: Option<IncludeScope>,
733    ) -> Result<TapResult, RunnerTransportError> {
734        #[derive(Serialize)]
735        struct Req<'a> {
736            selector: &'a Selector,
737        }
738        self.json_post("/double-tap", &Req { selector }, include)
739            .await
740    }
741
742    /// `POST /long-press` (v5.2 c3) — XCUIElement.press(forDuration:) public
743    /// API. `duration_ms` 来自 maestro yaml `duration:`, default 500.
744    /// Maestro `longPressOn` 同源.
745    pub async fn long_press(
746        &self,
747        selector: &Selector,
748        duration_ms: u64,
749        include: Option<IncludeScope>,
750    ) -> Result<TapResult, RunnerTransportError> {
751        #[derive(Serialize)]
752        struct Req<'a> {
753            selector: &'a Selector,
754            #[serde(rename = "durationMs")]
755            duration_ms: u64,
756        }
757        self.json_post(
758            "/long-press",
759            &Req {
760                selector,
761                duration_ms,
762            },
763            include,
764        )
765        .await
766    }
767
768    /// `POST /set-orientation` (v5.2 c5) — rotate sim via swift
769    /// `XCUIDevice.shared.orientation` setter. `orientation` literal:
770    /// "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight".
771    pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
772        #[derive(Serialize)]
773        struct Req<'a> {
774            orientation: &'a str,
775        }
776        let _: serde_json::Value = self
777            .json_post("/set-orientation", &Req { orientation }, None)
778            .await?;
779        Ok(())
780    }
781
782    /// `POST /swipe-at-norm-coord` (v5.2 c1) — Apple native UI event from-to
783    /// swipe. Sibling of [`Self::tap_at_norm_coord`] under §9 #3 escape hatch.
784    pub async fn swipe_at_norm_coord(
785        &self,
786        from: (f64, f64),
787        to: (f64, f64),
788    ) -> Result<(), RunnerTransportError> {
789        #[derive(Serialize)]
790        struct Req {
791            #[serde(rename = "fromNx")]
792            from_nx: f64,
793            #[serde(rename = "fromNy")]
794            from_ny: f64,
795            #[serde(rename = "toNx")]
796            to_nx: f64,
797            #[serde(rename = "toNy")]
798            to_ny: f64,
799        }
800        let _: serde_json::Value = self
801            .json_post(
802                "/swipe-at-norm-coord",
803                &Req {
804                    from_nx: from.0,
805                    from_ny: from.1,
806                    to_nx: to.0,
807                    to_ny: to.1,
808                },
809                None,
810            )
811            .await?;
812        Ok(())
813    }
814
815    /// `POST /find` (v1.2 C4) — boolean existence quick-probe.
816    pub async fn find(
817        &self,
818        selector: &Selector,
819        include: Option<IncludeScope>,
820    ) -> Result<bool, RunnerTransportError> {
821        #[derive(Serialize)]
822        struct Req<'a> {
823            selector: &'a Selector,
824        }
825        #[derive(Deserialize)]
826        struct Resp {
827            /// v0.2.0 wire field name. iOS SmixRunner emits `found`.
828            #[serde(default)]
829            found: bool,
830            /// Legacy alias — historical wire used `exists`.
831            #[serde(default)]
832            exists: bool,
833        }
834        // gol-611 §4 (v0.2.0) — previously read `exists || ok`, but
835        // `ok` is the transport-level "runner responded 200" flag
836        // that is TRUE for both found and not-found. That short-
837        // circuited the `find` predicate to always-true, defeating
838        // when-visible logic (insight Path B PoC repro). SmixRunner
839        // emits `found`; historical wire had `exists`. Accept
840        // either; do NOT fall back to `ok`.
841        let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
842        Ok(r.found || r.exists)
843    }
844
845    /// `POST /fill` — fill text into focused / matched input.
846    pub async fn fill(
847        &self,
848        selector: &Selector,
849        text: &str,
850        include: Option<IncludeScope>,
851    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
852        #[derive(Serialize)]
853        struct Req<'a> {
854            selector: &'a Selector,
855            text: &'a str,
856        }
857        self.json_post("/fill", &Req { selector, text }, include)
858            .await
859    }
860
861    /// `POST /clear` — clear focused / matched input.
862    pub async fn clear(
863        &self,
864        selector: &Selector,
865        include: Option<IncludeScope>,
866    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
867        #[derive(Serialize)]
868        struct Req<'a> {
869            selector: &'a Selector,
870        }
871        self.json_post("/clear", &Req { selector }, include).await
872    }
873
874    /// `POST /press-key` — press named key (Return/Tab/Delete/etc.).
875    pub async fn press_key(
876        &self,
877        key: KeyName,
878    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
879        #[derive(Serialize)]
880        struct Req {
881            key: KeyName,
882        }
883        self.json_post("/press-key", &Req { key }, None).await
884    }
885
886    /// `POST /scroll {selector, direction, include?}` — scroll-until-visible.
887    pub async fn scroll(
888        &self,
889        selector: &RunnerScrollSelector,
890        direction: SwipeDirection,
891        include: Option<IncludeScope>,
892    ) -> Result<u32, RunnerTransportError> {
893        #[derive(Serialize)]
894        struct Req<'a> {
895            selector: &'a RunnerScrollSelector,
896            direction: SwipeDirection,
897        }
898        #[derive(Deserialize)]
899        struct Resp {
900            #[serde(default)]
901            matched: Option<bool>,
902            #[serde(default)]
903            swipes: Option<u32>,
904        }
905        let r: Resp = self
906            .json_post(
907                "/scroll",
908                &Req {
909                    selector,
910                    direction,
911                },
912                include,
913            )
914            .await?;
915        let matched = r.matched.unwrap_or(false);
916        let swipes = r.swipes.unwrap_or(0);
917        if !matched {
918            // Driver layer is responsible for converting matched:false
919            // to ExpectationFailure(ELEMENT_NOT_FOUND); here we surface
920            // via MalformedBody when shape is missing entirely, otherwise
921            // return the swipe count for the caller to inspect.
922            //
923            // For c8 MVP we return swipes; driver wraps via RunnerScrollNotMatched.
924            return Err(RunnerTransportError::MalformedBody {
925                endpoint: "/scroll".into(),
926                detail: format!("not_matched after {swipes} swipes"),
927            });
928        }
929        Ok(swipes)
930    }
931
932    /// `POST /swipe-once {direction}` (v1.5 c5i-d) — single swipe, no probe.
933    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
934        #[derive(Serialize)]
935        struct Req {
936            direction: SwipeDirection,
937        }
938        let _: serde_json::Value = self
939            .json_post("/swipe-once", &Req { direction }, None)
940            .await?;
941        Ok(())
942    }
943
944    /// `POST /foreground {bundleId}` — bring app to foreground.
945    pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
946        #[derive(Serialize)]
947        struct Req<'a> {
948            #[serde(rename = "bundleId")]
949            bundle_id: &'a str,
950        }
951        let _: serde_json::Value = self
952            .json_post("/foreground", &Req { bundle_id }, None)
953            .await?;
954        Ok(())
955    }
956
957    /// `POST /hide-keyboard` (v1.5 c5g'').
958    pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
959        let _: serde_json::Value = self
960            .json_post("/hide-keyboard", &serde_json::json!({}), None)
961            .await?;
962        Ok(())
963    }
964
965    /// `POST /back` — back gesture.
966    pub async fn back(&self) -> Result<(), RunnerTransportError> {
967        let _: serde_json::Value = self
968            .json_post("/back", &serde_json::json!({}), None)
969            .await?;
970        Ok(())
971    }
972
973    // ---- recorder (v2.0) ----
974
975    /// `POST /record/start` — begin recording.
976    pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
977        let _: serde_json::Value = self
978            .json_post("/record/start", &serde_json::json!({}), None)
979            .await?;
980        Ok(())
981    }
982
983    /// `GET /record/poll` — drain recorded events.
984    pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
985        #[derive(Deserialize)]
986        struct Envelope {
987            events: Vec<RecordedEvent>,
988        }
989        let env: Envelope = self.json_get("/record/poll", None).await?;
990        Ok(env.events)
991    }
992
993    /// `POST /record/stop` — stop recording, drain final events.
994    pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
995        #[derive(Deserialize)]
996        struct Envelope {
997            events: Vec<RecordedEvent>,
998        }
999        let env: Envelope = self
1000            .json_post("/record/stop", &serde_json::json!({}), None)
1001            .await?;
1002        Ok(env.events)
1003    }
1004}