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