Skip to main content

smix_runner_client/
lib.rs

1//! smix-runner-client — HTTP IPC client to `swift-bridge/SmixRunnerCore`.
2//! Allows reqwest + tokio + thiserror + tracing deps.
3//!
4//! Wire-level 1:1 compatibility with the Swift side — any route shape /
5//! query param / response body shape change must be done in lockstep with
6//! `swift-bridge/Sources/SmixRunnerCore`.
7//!
8//! # Routes
9//!
10//! - `GET /health` — connectivity probe (memoized via [`HttpRunnerClient::ensure_reachable`])
11//! - `GET /tree?include=` — full a11y tree dump (returns [`A11yNode`])
12//! - `GET /system-popups?include=` — list of [`SystemPopup`]
13//! - `POST /tap {selector, mode, include?}` — selector → element tap
14//! - `POST /tap-at-norm-coord {nx, ny}` — Apple native event chain coord tap
15//! - `POST /find {selector, include?}` — boolean existence check
16//! - `POST /fill {selector, text, include?}` — fill text into input
17//! - `POST /clear {selector, include?}` — clear input
18//! - `POST /press-key {key}` — press named key (Return/Tab/etc.)
19//! - `POST /scroll {selector, direction, include?}` — scroll-until-visible
20//! - `POST /swipe-once {direction}` — single swipe gesture (no probe loop)
21//! - `POST /foreground {bundleId}` — bring app to foreground
22//! - `POST /hide-keyboard` — swipe-down to dismiss keyboard
23//! - `POST /back` — back gesture
24//! - `POST /record/start` — begin recording AX notifications
25//! - `GET /record/poll` — drain recorded events
26//! - `POST /record/stop` — stop recording, drain final events
27//!
28//! Per CLAUDE.md §9 #4 — never expose raw URL / sleep / xpath surfaces.
29//! All operations go through the typed methods below.
30
31#![doc(html_root_url = "https://docs.smix.dev/smix-runner-client")]
32
33use serde::{Deserialize, Serialize};
34use smix_input::{KeyName, SwipeDirection};
35use smix_screen::{A11yNode, derive_roles_recursive};
36use smix_selector::Selector;
37use std::sync::Arc;
38use std::sync::atomic::{AtomicBool, Ordering};
39use std::time::Duration;
40use thiserror::Error;
41
42// -------------------- Error types ----------------------------------------
43
44/// Transport-level failure (network, non-2xx, malformed body). Mirrors
45/// TS `RunnerTransportError` 1:1.
46#[derive(Debug, Error)]
47pub enum RunnerTransportError {
48    #[error("runner {endpoint} fetch failed: {source}")]
49    FetchFailed {
50        endpoint: String,
51        #[source]
52        source: reqwest::Error,
53    },
54    #[error("runner {endpoint} returned status {status}: {body}")]
55    NonSuccessStatus {
56        endpoint: String,
57        status: u16,
58        body: String,
59    },
60    #[error("runner {endpoint} returned non-JSON body: {source}")]
61    NonJsonBody {
62        endpoint: String,
63        #[source]
64        source: reqwest::Error,
65    },
66    #[error("runner {endpoint} returned malformed body: {detail}")]
67    MalformedBody { endpoint: String, detail: String },
68    #[error("runner {endpoint} unreachable: {message}")]
69    Unreachable { endpoint: String, message: String },
70    /// The runner returned
71    /// `{"ok":false,"error":"snapshot_unavailable"}` (or the enriched
72    /// body with `target`/`reason` fields). This is not a network
73    /// failure — it means XCUITest could not snapshot the app the client
74    /// asked about. Callers that see this after all retries can surface
75    /// an AI-readable hint about foreground state.
76    #[error("runner {endpoint}: app unavailable (target={target:?}, reason={reason:?})")]
77    AppUnavailable {
78        endpoint: String,
79        target: Option<String>,
80        reason: Option<String>,
81    },
82    /// Client-side rolling-window observation: consecutive requests
83    /// have been failing (5xx, unreachable, non-JSON). The runner is
84    /// still responding to the transport, but its answers are unsound
85    /// — surfacing this instead of silently returning stale bodies is
86    /// the whole point of [`HttpRunnerClient::with_liveness_window`].
87    #[error(
88        "runner degraded: {non_success_recent}/{window} recent requests failed \
89         (last_endpoint={last_endpoint}, last_error={last_error})"
90    )]
91    RunnerDegraded {
92        window: usize,
93        non_success_recent: usize,
94        last_endpoint: String,
95        last_error: String,
96    },
97    /// Client-side observation of a hard runner death. Triggered when a
98    /// `is_connect()` error fires AND a `/health` probe times out or
99    /// returns a non-2xx within 1 s. Distinct from
100    /// [`RunnerTransportError::Unreachable`] which fires on the initial
101    /// reachability probe; `RunnerDied` fires mid-session after
102    /// `ensure_reachable` had already succeeded.
103    #[error("runner died mid-session: last_seen_ms={last_seen_ms}, last_error={last_error}")]
104    RunnerDied {
105        last_seen_ms: u64,
106        last_error: String,
107    },
108}
109
110/// Client-side rolling window tracking the last N request outcomes.
111/// Used by `with_liveness_window` to distinguish "runner is alive and
112/// healthily returning some errors" from "runner has silently drifted
113/// into a degraded state". See [`RunnerTransportError::RunnerDegraded`]
114/// and [`RunnerTransportError::RunnerDied`].
115#[derive(Debug, Clone)]
116pub(crate) struct LivenessState {
117    window: usize,
118    /// True = success, false = non-success. Front = oldest.
119    outcomes: std::collections::VecDeque<bool>,
120    last_endpoint: String,
121    last_error: String,
122    /// Milliseconds since UNIX epoch of the last successful request.
123    last_seen_ms: u64,
124    /// True once we've fired a RunnerDied. Prevents re-firing until a
125    /// fresh success clears it.
126    died: bool,
127}
128
129impl LivenessState {
130    fn with_window(window: usize) -> Self {
131        Self {
132            window: window.max(2),
133            outcomes: std::collections::VecDeque::with_capacity(window.max(2)),
134            last_endpoint: String::new(),
135            last_error: String::new(),
136            last_seen_ms: 0,
137            died: false,
138        }
139    }
140
141    fn record(&mut self, endpoint: &str, success: bool, error: &str) {
142        while self.outcomes.len() >= self.window {
143            self.outcomes.pop_front();
144        }
145        self.outcomes.push_back(success);
146        self.last_endpoint = endpoint.to_string();
147        if success {
148            self.last_error.clear();
149            self.last_seen_ms = std::time::SystemTime::now()
150                .duration_since(std::time::UNIX_EPOCH)
151                .map(|d| d.as_millis() as u64)
152                .unwrap_or(0);
153            self.died = false;
154        } else {
155            self.last_error = error.to_string();
156        }
157    }
158
159    /// If the last `window` outcomes have a majority-non-success shape,
160    /// return the degraded error variant. Caller wraps into the outer
161    /// [`RunnerTransportError`].
162    fn degraded(&self) -> Option<RunnerTransportError> {
163        if self.outcomes.len() < self.window {
164            return None;
165        }
166        let bad = self.outcomes.iter().filter(|ok| !**ok).count();
167        if bad * 2 > self.window {
168            Some(RunnerTransportError::RunnerDegraded {
169                window: self.window,
170                non_success_recent: bad,
171                last_endpoint: self.last_endpoint.clone(),
172                last_error: self.last_error.clone(),
173            })
174        } else {
175            None
176        }
177    }
178}
179
180/// Per-request context carried across the wire via the
181/// `App-Bundle-Id` and `App-Activate` HTTP headers. Client sets
182/// these via `HttpRunnerClient::with_target_bundle_id` /
183/// `with_auto_activate`, or per-call by cloning the client with a
184/// modified context. The runner side reads them into
185/// `SmixRunnerServer.currentContext` (task-local) and passes to
186/// `resolveApp()` in every app-touching handler.
187///
188/// The two-field split is intentional: `bundle_id` is "which app do I
189/// want to talk to", `activate` is "should we bring it foreground first".
190#[derive(Clone, Debug, Default)]
191pub struct RequestContext {
192    pub bundle_id: Option<String>,
193    pub activate: bool,
194}
195
196/// `POST /tap` returned `404 / not_found` — element matched no node.
197/// Mirrors TS `TapNotFoundError`.
198#[derive(Debug, Error)]
199#[error("tap not_found for selector {selector_json}: {body}")]
200pub struct TapNotFoundError {
201    pub selector_json: String,
202    pub body: String,
203}
204
205/// `POST /scroll` returned `200 / matched:false / swipes:N` — scroll
206/// exhausted N swipes without surfacing the target. Mirrors TS
207/// `RunnerScrollNotMatched`.
208#[derive(Debug, Error)]
209#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
210pub struct RunnerScrollNotMatched {
211    pub selector_json: String,
212    pub swipes: u32,
213}
214
215/// `POST /swipe-once` returned `200 / ok:false / vanished_during_swipe`.
216/// Mirrors TS `RunnerSwipeOnceFailure`.
217#[derive(Debug, Error)]
218#[error("swipeOnce failed: {detail}")]
219pub struct RunnerSwipeOnceFailure {
220    pub detail: String,
221}
222
223/// Normalized OCR bounding box returned by
224/// [`HttpRunnerClient::find_text_by_ocr`]. Coordinates are normalized to
225/// `[0, 1]` in UIKit coord space (top-left origin, y-down). Apple Vision's
226/// native bbox is bottom-left origin + y-up — the swift handler converts
227/// before returning so all consumers see UIKit coords.
228#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
229pub struct OcrFrame {
230    /// Top-left x in `[0, 1]`.
231    pub nx: f64,
232    /// Top-left y in `[0, 1]`.
233    pub ny: f64,
234    /// Width in `[0, 1]`.
235    pub w: f64,
236    /// Height in `[0, 1]`.
237    pub h: f64,
238}
239
240impl OcrFrame {
241    /// Center x in `[0, 1]`.
242    #[must_use]
243    pub fn mid_x(&self) -> f64 {
244        self.nx + self.w * 0.5
245    }
246    /// Center y in `[0, 1]`.
247    #[must_use]
248    pub fn mid_y(&self) -> f64 {
249        self.ny + self.h * 0.5
250    }
251}
252
253// -------------------- Wire types ----------------------------------------
254//
255// Wire types live in the smix-runner-wire stone. The HTTP client
256// (this crate) re-exports them so existing consumers don't break.
257// Anyone needing the wire shapes without the reqwest+tokio HTTP impl
258// should depend on smix-runner-wire directly.
259
260pub use smix_runner_wire::{
261    FindRequest, FindResponse, HealthProcessInfo, HealthResponse, HealthTestHostInfo, IncludeScope,
262    KeyboardStages, RecordEventsResponse, RecordedEvent, RunnerIncludeOpts, RunnerKeyboardResult,
263    RunnerScrollSelector, ScrollResponse, SessionAppLifecycleRequest,
264    SessionAppLifecycleResponse, SessionCloseAllResponse, SessionCloseRequest,
265    SessionCloseResponse, SessionListResponse, SessionOpenRequest, SessionOpenResponse,
266    SessionRelaunchAppRequest, SessionRelaunchAppResponse, SessionRenewActivationRequest,
267    SessionRenewActivationResponse, SessionSummary, SimHealthWireState,
268    DiagnosticDumpResponse, SubprocessRecord as WireSubprocessRecord, SystemPopup,
269    SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
270    TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
271};
272
273// -------------------- Transport retry constants -------------------------
274
275/// Total transport attempts (1 initial + 2 retries) for transient reqwest
276/// connection errors. See `send_with_retry` doc.
277const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
278/// Backoff between transport retry attempts.
279const TRANSPORT_BACKOFF_MS: u64 = 100;
280
281/// Classify a non-2xx runner response body. When the body
282/// carries `snapshot_unavailable` (bare shape) or the enriched
283/// `{"ok":false,"error":"snapshot_unavailable","target":"...","reason":"..."}`
284/// shape, lift it to a first-class
285/// `RunnerTransportError::AppUnavailable` variant. Otherwise fall
286/// through to the generic `NonSuccessStatus`.
287fn classify_error_body(endpoint: &str, status: u16, body: &str) -> RunnerTransportError {
288    if body.contains("snapshot_unavailable") {
289        // Parse enriched body when present. Any parse miss falls back to
290        // just the marker string — still surfaces useful hint upstream.
291        #[derive(Deserialize)]
292        struct Unavailable {
293            #[serde(default)]
294            target: Option<String>,
295            #[serde(default)]
296            reason: Option<String>,
297        }
298        let parsed: Option<Unavailable> = serde_json::from_str(body).ok();
299        return RunnerTransportError::AppUnavailable {
300            endpoint: endpoint.to_string(),
301            target: parsed.as_ref().and_then(|p| p.target.clone()),
302            reason: parsed.as_ref().and_then(|p| p.reason.clone()),
303        };
304    }
305    RunnerTransportError::NonSuccessStatus {
306        endpoint: endpoint.to_string(),
307        status,
308        body: body.chars().take(200).collect(),
309    }
310}
311
312// -------------------- Client --------------------------------------------
313
314/// HTTP client to the swift-side SmixRunnerCore. Async (tokio-based).
315/// Constructed once per Cell; `ensure_reachable` memoizes a successful
316/// `/health` probe so subsequent calls don't re-probe.
317#[derive(Debug)]
318pub struct HttpRunnerClient {
319    base: String,
320    client: reqwest::Client,
321    reachable: Arc<AtomicBool>,
322    /// Target bundle id, sent as `App-Bundle-Id` header on every
323    /// request. `None` = client doesn't say; runner uses its
324    /// boot-time default `app`.
325    target_bundle_id: Option<String>,
326    /// If `true`, sends `App-Activate: true` on every request so the
327    /// runner calls `.activate()` on the resolved target before
328    /// operating. Auto-recovers from cases where XCUITest's implicit
329    /// app-under-test binds to the wrong foreground.
330    auto_activate: bool,
331    /// Sent as `Input-Dispatch-Mode` header on every request.
332    /// `None` = no header sent (runner uses default).
333    input_dispatch_mode: Option<InputDispatchMode>,
334    /// Sent as `Session-Id` header on every request. Set by
335    /// `Session::wrap` after `open_session()`. When present, the
336    /// runner short-circuits its per-request `resolveApp()` and reuses
337    /// the session's cached `XCUIApplication` binding — this is what
338    /// eliminates the activation storm on long-running gates.
339    session_id: Option<String>,
340    /// Rolling-window state for liveness observability. `None` when
341    /// disabled (default). See [`Self::with_liveness_window`].
342    liveness: Arc<std::sync::Mutex<Option<LivenessState>>>,
343    /// Sim-side health monitor. `None` when disabled (default). See
344    /// [`Self::with_sim_health`]. When present, `/health` outcomes
345    /// feed `SimHealthMonitor::record_health_ok` / `_fail`, so a
346    /// subscriber gets `Degraded` / `Dead` transitions without polling.
347    sim_health: Option<smix_sim_health::SimHealthMonitor>,
348    /// v1.0.4 §D7 — session state atomic shared with the SDK Session.
349    /// Updated on every response by parsing the `X-Sim-Health` header
350    /// (values `healthy` | `degraded` | `cycling` | `dead`). Unknown /
351    /// missing header leaves the state unchanged.
352    session_state: Arc<std::sync::Mutex<Option<Arc<std::sync::atomic::AtomicU8>>>>,
353}
354
355/// Input dispatch mode for the `Input-Dispatch-Mode` header.
356/// Runner-side interpretation lives in `SmixRunnerCore/FillRoute.swift`.
357#[derive(Clone, Copy, Debug, PartialEq, Eq)]
358pub enum InputDispatchMode {
359    /// a11y-anchored dispatch (default; XCUIElement.typeText).
360    A11y,
361    /// Raw key events via IOHID / daemon; skip a11y-focus resolution.
362    /// Covers the RN hidden-input case where a11y-focus lookup returns
363    /// nothing.
364    KeyEvents,
365    /// Try a11y first; on ElementNotFound fall back to key-events.
366    Auto,
367}
368
369impl InputDispatchMode {
370    fn header_value(self) -> &'static str {
371        match self {
372            InputDispatchMode::A11y => "a11y",
373            InputDispatchMode::KeyEvents => "key-events",
374            InputDispatchMode::Auto => "auto",
375        }
376    }
377}
378
379impl HttpRunnerClient {
380    /// Construct a client targeting `http://127.0.0.1:{port}`.
381    pub fn new(port: u16) -> Self {
382        Self::with_base(format!("http://127.0.0.1:{port}"))
383    }
384
385    /// Construct with an explicit base URL (test / non-localhost cases).
386    pub fn with_base<S: Into<String>>(base: S) -> Self {
387        let client = reqwest::Client::builder()
388            .timeout(Duration::from_secs(15))
389            .build()
390            .expect("reqwest::Client::builder default never fails");
391        HttpRunnerClient {
392            base: base.into(),
393            client,
394            reachable: Arc::new(AtomicBool::new(false)),
395            target_bundle_id: None,
396            auto_activate: false,
397            input_dispatch_mode: None,
398            session_id: None,
399            liveness: Arc::new(std::sync::Mutex::new(None)),
400            sim_health: None,
401            session_state: Arc::new(std::sync::Mutex::new(None)),
402        }
403    }
404
405    /// v1.0.4 §D7 — attach the SDK Session's state atomic so the
406    /// client updates it on every response by parsing `X-Sim-Health`.
407    /// Called by `App::open_session`. External callers rarely need
408    /// this directly.
409    pub fn attach_session_state(
410        &self,
411        state: Arc<std::sync::atomic::AtomicU8>,
412    ) {
413        let mut guard = self
414            .session_state
415            .lock()
416            .expect("session_state mutex must not be poisoned");
417        *guard = Some(state);
418    }
419
420    fn update_session_state_from_header(&self, header: Option<&str>) {
421        let Some(value) = header else {
422            return;
423        };
424        let Some(state) = smix_runner_wire::SimHealthWireState::from_header(value) else {
425            return;
426        };
427        let byte = match state {
428            smix_runner_wire::SimHealthWireState::Healthy => 0u8,
429            smix_runner_wire::SimHealthWireState::Degraded => 1u8,
430            smix_runner_wire::SimHealthWireState::Cycling => 2u8,
431            smix_runner_wire::SimHealthWireState::Dead => 3u8,
432            _ => return,
433        };
434        let guard = self
435            .session_state
436            .lock()
437            .expect("session_state mutex must not be poisoned");
438        if let Some(atomic) = guard.as_ref() {
439            atomic.store(byte, std::sync::atomic::Ordering::Release);
440        }
441    }
442
443    /// Set the target bundle id sent as `App-Bundle-Id` header on
444    /// every subsequent request. Runner rebinds
445    /// `XCUIApplication(bundleIdentifier:)` per request if this differs
446    /// from its boot-time default.
447    #[must_use]
448    pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
449        self.target_bundle_id = Some(bundle.into());
450        self
451    }
452
453    /// Mutable variant of [`Self::with_target_bundle_id`]. Used from
454    /// the driver-trait pass-through where the driver holds the client
455    /// by value and can't easily use the builder shape.
456    pub fn set_target_bundle_id<S: Into<String>>(&mut self, bundle: S) {
457        self.target_bundle_id = Some(bundle.into());
458    }
459
460    /// When set, every subsequent request sends `App-Activate: true`,
461    /// causing the runner to `.activate()` the resolved target before
462    /// operating.
463    #[must_use]
464    pub fn with_auto_activate(mut self, activate: bool) -> Self {
465        self.auto_activate = activate;
466        self
467    }
468
469    /// Mutable variant of [`Self::with_auto_activate`].
470    pub fn set_auto_activate(&mut self, activate: bool) {
471        self.auto_activate = activate;
472    }
473
474    /// Set the input dispatch mode header.
475    /// Sent as `Input-Dispatch-Mode: <mode>` on every request that
476    /// touches text input (currently `/fill` + `/clear`). Runner routes:
477    /// - `a11y` (default) — resolve focus via a11y tree, dispatch to
478    ///   XCUIElement.typeText
479    /// - `key-events` — skip a11y-focus resolution, send raw key events
480    ///   via IOHID / daemon
481    /// - `auto` — try a11y first, fall back to key-events on
482    ///   ElementNotFound
483    ///
484    /// `--force-key-events` on `smix run` sets this to `key-events`.
485    #[must_use]
486    pub fn with_input_dispatch_mode(mut self, mode: InputDispatchMode) -> Self {
487        self.input_dispatch_mode = Some(mode);
488        self
489    }
490
491    /// Mutable variant.
492    pub fn set_input_dispatch_mode(&mut self, mode: InputDispatchMode) {
493        self.input_dispatch_mode = Some(mode);
494    }
495
496    /// Accessor for the current target bundle. Used by callers
497    /// that want to log or diagnose.
498    pub fn target_bundle_id(&self) -> Option<&str> {
499        self.target_bundle_id.as_deref()
500    }
501
502    /// Accessor for the current auto-activate flag.
503    pub fn auto_activate(&self) -> bool {
504        self.auto_activate
505    }
506
507    /// Attach a session id sent as the `Session-Id` header on every
508    /// subsequent request. When the runner sees this header it looks up
509    /// the session's cached `XCUIApplication` and skips the per-request
510    /// `.activate()`, eliminating the activation storm on long-running
511    /// gates. Typically not called directly — use `open_session()` +
512    /// a session wrapper instead.
513    pub fn set_session_id<S: Into<String>>(&mut self, id: S) {
514        self.session_id = Some(id.into());
515    }
516
517    /// Clear a previously-set session id, reverting to legacy per-request
518    /// `resolveApp()` (now itself rate-limited to at most one `.activate()`
519    /// per 5 s per bundle-id).
520    pub fn clear_session_id(&mut self) {
521        self.session_id = None;
522    }
523
524    /// The session id in force for outgoing requests, or `None` if the
525    /// legacy per-request rebind path is in use.
526    pub fn session_id(&self) -> Option<&str> {
527        self.session_id.as_deref()
528    }
529
530    /// Enable liveness observability. The client tracks the last `window`
531    /// request outcomes; if a majority are non-success (5xx, unreachable,
532    /// connection-refused), subsequent calls surface
533    /// [`RunnerTransportError::RunnerDegraded`] instead of returning
534    /// silent stale data. On any `is_connect()` failure the client also
535    /// probes `/health`; if that fails within 1 s the next call surfaces
536    /// [`RunnerTransportError::RunnerDied`].
537    ///
538    /// Idempotent; safe to call more than once (last window size wins).
539    /// Default is disabled (behavior identical to v1.0.1).
540    #[must_use]
541    pub fn with_liveness_window(self, window: usize) -> Self {
542        {
543            let mut guard = self
544                .liveness
545                .lock()
546                .expect("liveness mutex must not be poisoned");
547            *guard = Some(LivenessState::with_window(window));
548        }
549        self
550    }
551
552    /// Attach a [`smix_sim_health::SimHealthMonitor`] that receives
553    /// `/health` outcome observations. Every `health()`, `health_detail()`
554    /// and internal `probe_death()` call feeds
555    /// `SimHealthMonitor::record_health_ok` / `record_health_fail`, and
556    /// subscribers get a `Degraded` / `Dead` transition without polling.
557    ///
558    /// Additive over `with_liveness_window`; both may be enabled at the
559    /// same time — the liveness window handles per-call transport
560    /// classification, the sim-health monitor aggregates across the
561    /// wider observation surface (screenshot wall time, watched
562    /// processes) fed by other crates.
563    ///
564    /// Since smix 1.0.4.
565    #[must_use]
566    pub fn with_sim_health(mut self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
567        self.sim_health = Some(monitor);
568        self
569    }
570
571    /// Mutable variant of [`Self::with_sim_health`].
572    pub fn set_sim_health(&mut self, monitor: smix_sim_health::SimHealthMonitor) {
573        self.sim_health = Some(monitor);
574    }
575
576    /// Accessor for the attached sim-health monitor, or `None` if the
577    /// caller opted out. Used by driver / SDK layers that need to
578    /// subscribe to state transitions or read the current classification.
579    pub fn sim_health(&self) -> Option<&smix_sim_health::SimHealthMonitor> {
580        self.sim_health.as_ref()
581    }
582
583    /// Apply per-request context headers to a reqwest builder.
584    /// Every method that constructs a request calls this so the
585    /// `App-Bundle-Id` / `App-Activate` / `Session-Id` /
586    /// `Input-Dispatch-Mode` headers are sent uniformly.
587    fn apply_context(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
588        let mut b = builder;
589        if let Some(bundle) = &self.target_bundle_id {
590            b = b.header("App-Bundle-Id", bundle);
591        }
592        if self.auto_activate {
593            b = b.header("App-Activate", "true");
594        }
595        if let Some(mode) = self.input_dispatch_mode {
596            b = b.header("Input-Dispatch-Mode", mode.header_value());
597        }
598        if let Some(sid) = &self.session_id {
599            b = b.header("Session-Id", sid);
600        }
601        b
602    }
603
604    /// Base URL accessor.
605    pub fn base(&self) -> &str {
606        &self.base
607    }
608
609    // ---- low-level helpers ----
610
611    fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
612        match include {
613            Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
614            None => format!("{}{}", self.base, endpoint),
615        }
616    }
617
618    /// Wire-level transport retry for transient reqwest connection
619    /// errors (`is_connect()` / `is_request()` — pre-server-receipt failures
620    /// raised before any bytes leave the local socket, idempotent-safe for
621    /// every route). Server-side `5xx` / `NonJsonBody` are NOT retried here:
622    /// those are semantic responses, retried (or not) by callers like
623    /// `driver.find`'s 500 retry loop or `driver.wait_for`'s selector poll.
624    ///
625    /// Budget: 3 attempts total (1 initial + 2 retries) × `TRANSPORT_BACKOFF_MS`
626    /// between attempts. Catches sim/runner socket hiccups mid-flow
627    /// without inflating happy-path latency: retries only fire on actual failure.
628    async fn send_with_retry<F>(
629        &self,
630        endpoint: &str,
631        builder_fn: F,
632    ) -> Result<reqwest::Response, RunnerTransportError>
633    where
634        F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
635    {
636        // Preflight: if liveness is enabled and previously observed the
637        // runner died, short-circuit until the caller resets it (via a
638        // successful direct `/health`).
639        if let Some(err) = self.preflight_liveness() {
640            return Err(err);
641        }
642        let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
643        loop {
644            attempts_left -= 1;
645            match builder_fn(&self.client).send().await {
646                Ok(res) => {
647                    // v1.0.4 §D7 — parse `X-Sim-Health` response header
648                    // and propagate to the attached Session state atomic
649                    // (if any). Absent header leaves state unchanged.
650                    let sim_health_hdr = res
651                        .headers()
652                        .get("X-Sim-Health")
653                        .and_then(|v| v.to_str().ok())
654                        .map(|s| s.to_string());
655                    self.update_session_state_from_header(sim_health_hdr.as_deref());
656                    // Record but don't classify yet — status-level handling
657                    // happens in the json_* helpers so the "5xx but recovered
658                    // via retry" case is still counted as success.
659                    return Ok(res);
660                }
661                Err(e) => {
662                    let retryable = e.is_connect() || e.is_request();
663                    if !retryable || attempts_left == 0 {
664                        let is_connect = e.is_connect();
665                        let err_str = format!("{e}");
666                        self.record_outcome(endpoint, false, &err_str);
667                        if is_connect && let Some(died) = self.probe_death(endpoint, &err_str).await
668                        {
669                            return Err(died);
670                        }
671                        return Err(RunnerTransportError::FetchFailed {
672                            endpoint: endpoint.to_string(),
673                            source: e,
674                        });
675                    }
676                    tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
677                }
678            }
679        }
680    }
681
682    fn record_outcome(&self, endpoint: &str, success: bool, err: &str) {
683        let mut guard = self
684            .liveness
685            .lock()
686            .expect("liveness mutex must not be poisoned");
687        if let Some(state) = guard.as_mut() {
688            state.record(endpoint, success, err);
689        }
690    }
691
692    fn preflight_liveness(&self) -> Option<RunnerTransportError> {
693        let guard = self
694            .liveness
695            .lock()
696            .expect("liveness mutex must not be poisoned");
697        if let Some(state) = guard.as_ref() {
698            if state.died {
699                return Some(RunnerTransportError::RunnerDied {
700                    last_seen_ms: state.last_seen_ms,
701                    last_error: state.last_error.clone(),
702                });
703            }
704            return state.degraded();
705        }
706        None
707    }
708
709    async fn probe_death(&self, endpoint: &str, err_str: &str) -> Option<RunnerTransportError> {
710        // Snapshot `last_seen_ms` before probing so the caller can
711        // report "last successful contact at X".
712        let last_seen_ms = {
713            let guard = self
714                .liveness
715                .lock()
716                .expect("liveness mutex must not be poisoned");
717            guard.as_ref().map(|s| s.last_seen_ms).unwrap_or(0)
718        };
719        let url = format!("{}/health", self.base);
720        let alive = tokio::time::timeout(Duration::from_millis(1000), self.client.get(&url).send())
721            .await
722            .ok()
723            .and_then(|r| r.ok())
724            .map(|r| r.status().is_success())
725            .unwrap_or(false);
726        if alive {
727            return None;
728        }
729        {
730            let mut guard = self
731                .liveness
732                .lock()
733                .expect("liveness mutex must not be poisoned");
734            if let Some(state) = guard.as_mut() {
735                state.died = true;
736                state.last_endpoint = endpoint.to_string();
737                state.last_error = err_str.to_string();
738            }
739        }
740        Some(RunnerTransportError::RunnerDied {
741            last_seen_ms,
742            last_error: err_str.to_string(),
743        })
744    }
745
746    async fn json_get<T: for<'de> Deserialize<'de>>(
747        &self,
748        endpoint: &str,
749        include: Option<IncludeScope>,
750    ) -> Result<T, RunnerTransportError> {
751        let url = self.url(endpoint, include);
752        let res = self
753            .send_with_retry(endpoint, |c| self.apply_context(c.get(&url)))
754            .await?;
755        let status = res.status();
756        if !status.is_success() {
757            let body = res.text().await.unwrap_or_default();
758            let err = classify_error_body(endpoint, status.as_u16(), &body);
759            self.record_outcome(endpoint, false, &err.to_string());
760            return Err(err);
761        }
762        match res.json::<T>().await {
763            Ok(v) => {
764                self.record_outcome(endpoint, true, "");
765                Ok(v)
766            }
767            Err(source) => {
768                let err = RunnerTransportError::NonJsonBody {
769                    endpoint: endpoint.to_string(),
770                    source,
771                };
772                self.record_outcome(endpoint, false, &err.to_string());
773                Err(err)
774            }
775        }
776    }
777
778    async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
779        &self,
780        endpoint: &str,
781        body: &B,
782        include: Option<IncludeScope>,
783    ) -> Result<T, RunnerTransportError> {
784        let url = self.url(endpoint, include);
785        let res = self
786            .send_with_retry(endpoint, |c| self.apply_context(c.post(&url).json(body)))
787            .await?;
788        let status = res.status();
789        if !status.is_success() {
790            let body = res.text().await.unwrap_or_default();
791            let err = classify_error_body(endpoint, status.as_u16(), &body);
792            self.record_outcome(endpoint, false, &err.to_string());
793            return Err(err);
794        }
795        match res.json::<T>().await {
796            Ok(v) => {
797                self.record_outcome(endpoint, true, "");
798                Ok(v)
799            }
800            Err(source) => {
801                let err = RunnerTransportError::NonJsonBody {
802                    endpoint: endpoint.to_string(),
803                    source,
804                };
805                self.record_outcome(endpoint, false, &err.to_string());
806                Err(err)
807            }
808        }
809    }
810
811    // ---- public methods (mirrors TS RunnerClient surface) ----
812
813    /// `GET /health` — bare alive probe (no memoization). Returns true on 2xx.
814    pub async fn health(&self) -> bool {
815        let url = self.url("/health", None);
816        let ok = match self.client.get(&url).send().await {
817            Ok(res) => res.status().is_success(),
818            Err(_) => false,
819        };
820        if let Some(monitor) = &self.sim_health {
821            if ok {
822                monitor.record_health_ok();
823            } else {
824                monitor.record_health_fail();
825            }
826        }
827        ok
828    }
829
830    /// `GET /health` memoized — first call probes; subsequent are no-ops
831    /// after one success. Failure raises [`RunnerTransportError::Unreachable`].
832    /// Mirrors TS `ensureReachable`.
833    pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
834        if self.reachable.load(Ordering::Acquire) {
835            return Ok(());
836        }
837        if self.health().await {
838            self.reachable.store(true, Ordering::Release);
839            return Ok(());
840        }
841        Err(RunnerTransportError::Unreachable {
842            endpoint: "/health".into(),
843            message: format!("SmixRunner not reachable at {}", self.base),
844        })
845    }
846
847    /// `GET /health` extended — parses the v1.0.2+ JSON body carrying
848    /// uptime / activation counters / open-session count. On pre-v1.0.2
849    /// runners returning a bare 200 with no body, returns a default
850    /// [`smix_runner_wire::HealthResponse`] with only `ok = true` set.
851    pub async fn health_detail(
852        &self,
853    ) -> Result<smix_runner_wire::HealthResponse, RunnerTransportError> {
854        let url = self.url("/health", None);
855        let res = self.client.get(&url).send().await.map_err(|source| {
856            if let Some(monitor) = &self.sim_health {
857                monitor.record_health_fail();
858            }
859            RunnerTransportError::FetchFailed {
860                endpoint: "/health".into(),
861                source,
862            }
863        })?;
864        let status = res.status();
865        if !status.is_success() {
866            if let Some(monitor) = &self.sim_health {
867                monitor.record_health_fail();
868            }
869            let body = res.text().await.unwrap_or_default();
870            return Err(RunnerTransportError::NonSuccessStatus {
871                endpoint: "/health".into(),
872                status: status.as_u16(),
873                body,
874            });
875        }
876        if let Some(monitor) = &self.sim_health {
877            monitor.record_health_ok();
878        }
879        // Legacy runners (< v1.0.2) return an empty body; treat parse
880        // failure as {ok: true} to keep the "health passed" invariant.
881        let text = res.text().await.unwrap_or_default();
882        if text.trim().is_empty() {
883            return Ok(smix_runner_wire::HealthResponse {
884                ok: true,
885                ..Default::default()
886            });
887        }
888        Ok(
889            serde_json::from_str(&text).unwrap_or(smix_runner_wire::HealthResponse {
890                ok: true,
891                ..Default::default()
892            }),
893        )
894    }
895
896    /// `POST /session/open` — reserve a runner-side XCUIApplication
897    /// binding and (optionally) activate it once. The returned session
898    /// id becomes the `Session-Id` header on subsequent requests via
899    /// [`Self::set_session_id`] or a session wrapper.
900    pub async fn open_session(
901        &self,
902        req: &smix_runner_wire::SessionOpenRequest,
903    ) -> Result<smix_runner_wire::SessionOpenResponse, RunnerTransportError> {
904        self.json_post("/session/open", req, None).await
905    }
906
907    /// `POST /session/close` — release a previously-opened session.
908    /// Idempotent; closing an unknown session returns `ok = true`.
909    pub async fn close_session(
910        &self,
911        req: &smix_runner_wire::SessionCloseRequest,
912    ) -> Result<smix_runner_wire::SessionCloseResponse, RunnerTransportError> {
913        self.json_post("/session/close", req, None).await
914    }
915
916    /// `POST /session/renew-activation` — re-issue `.activate()` on the
917    /// session's cached binding, subject to the runner-side per-session
918    /// 5 s rate limit. Escape hatch for consumers that detect drift.
919    pub async fn renew_session_activation(
920        &self,
921        req: &smix_runner_wire::SessionRenewActivationRequest,
922    ) -> Result<smix_runner_wire::SessionRenewActivationResponse, RunnerTransportError> {
923        self.json_post("/session/renew-activation", req, None).await
924    }
925
926    /// v1.0.4 §D5 — `POST /session/close-all` — clear every open
927    /// session on the runner. Used by `smix runner cycle` and the
928    /// runner-side supervisor to drop stale bindings after a cycle.
929    /// Idempotent — closing an empty session table returns
930    /// `{ok:true, closed:0}`.
931    pub async fn close_all_sessions(
932        &self,
933    ) -> Result<smix_runner_wire::SessionCloseAllResponse, RunnerTransportError> {
934        // No request body — send an empty JSON object.
935        self.json_post("/session/close-all", &serde_json::json!({}), None)
936            .await
937    }
938
939    /// v1.0.4 §D14 — `POST /session/relaunch-app` — instruct the runner
940    /// to `terminate()` + `launch()` the session's cached
941    /// `XCUIApplication` binding IN PLACE. Preserves session id and
942    /// XCUITest binding; recovers from a downstream app crash without
943    /// cycling the runner.
944    pub async fn relaunch_session_app(
945        &self,
946        req: &smix_runner_wire::SessionRelaunchAppRequest,
947    ) -> Result<smix_runner_wire::SessionRelaunchAppResponse, RunnerTransportError> {
948        self.json_post("/session/relaunch-app", req, None).await
949    }
950
951    /// v1.0.5 §D1 — `POST /session/list` — enumerate every session
952    /// currently known to the runner. Useful for diagnostics + the
953    /// supervisor + SDK `Session::still_valid()` probe after a cycle.
954    pub async fn list_sessions(
955        &self,
956    ) -> Result<smix_runner_wire::SessionListResponse, RunnerTransportError> {
957        self.json_post("/session/list", &serde_json::json!({}), None)
958            .await
959    }
960
961    /// v1.0.8 §D1 — `POST /session/terminate-app` — cooperative
962    /// `XCUIApplication.terminate()` on the session's cached binding
963    /// via testmanagerd (NOT `simctl terminate` SIGKILL). Does not
964    /// signal `com.apple.ReportCrash`. Paired with
965    /// [`Self::launch_session_app`] and a host-side
966    /// `SimctlClient::clear_app_sandbox` invocation to implement the
967    /// `Session::reset_app_data` orchestration that eliminates the
968    /// "Insight quit unexpectedly" system dialog.
969    pub async fn terminate_session_app(
970        &self,
971        req: &smix_runner_wire::SessionAppLifecycleRequest,
972    ) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
973        self.json_post("/session/terminate-app", req, None).await
974    }
975
976    /// v1.0.8 §D1 — `POST /session/launch-app` — cooperative
977    /// `XCUIApplication.launch()` on the session's cached binding.
978    /// Companion of [`Self::terminate_session_app`].
979    pub async fn launch_session_app(
980        &self,
981        req: &smix_runner_wire::SessionAppLifecycleRequest,
982    ) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
983        self.json_post("/session/launch-app", req, None).await
984    }
985
986    /// v1.0.7 §D5 — `POST /diagnostic/dump` — one-shot post-mortem
987    /// snapshot of the runner's runtime state. Bundles the recent
988    /// subprocess ring buffer, open sessions, sim-health state,
989    /// supervisor pid, and uptime. `smix diagnostic dump` calls this
990    /// then pretty-prints.
991    ///
992    /// Legacy runners (v1.0.6 and earlier) return 404; consumers can
993    /// gracefully fall back to the client-side ring buffer only.
994    pub async fn diagnostic_dump(
995        &self,
996    ) -> Result<smix_runner_wire::DiagnosticDumpResponse, RunnerTransportError> {
997        self.json_post("/diagnostic/dump", &serde_json::json!({}), None)
998            .await
999    }
1000
1001    /// `GET /tree?include=` — full a11y tree dump.
1002    ///
1003    /// Post-deser pass [`derive_roles_recursive`] fills
1004    /// `A11yNode.role` from `raw_type` because the Swift `/tree` route
1005    /// only emits `rawType` on the wire. Without this, `Selector::Role`
1006    /// would never match real-sim payloads.
1007    pub async fn get_tree(
1008        &self,
1009        include: Option<IncludeScope>,
1010    ) -> Result<A11yNode, RunnerTransportError> {
1011        let mut tree: A11yNode = self.json_get("/tree", include).await?;
1012        derive_roles_recursive(&mut tree);
1013        Ok(tree)
1014    }
1015
1016    /// `GET /system-popups?include=` — list system popups.
1017    pub async fn system_popups(
1018        &self,
1019        include: Option<IncludeScope>,
1020    ) -> Result<Vec<SystemPopup>, RunnerTransportError> {
1021        #[derive(Deserialize)]
1022        struct Envelope {
1023            popups: Vec<SystemPopup>,
1024        }
1025        let env: Envelope = self.json_get("/system-popups", include).await?;
1026        Ok(env.popups)
1027    }
1028
1029    /// `POST /system-popup-action`. Tap a button
1030    /// on a previously enumerated popup. `popup_id` and `button_id` are
1031    /// the `Popup.id` / `PopupButton.id` strings returned by
1032    /// [`Self::system_popups`]; the runner walks the same scan order so
1033    /// the round-trip does not need a host-side id map.
1034    ///
1035    /// Returns `Ok(true)` when the runner matched both ids and dispatched
1036    /// the tap, `Ok(false)` when the runner returned a 404 not_found
1037    /// (popup id, button id, or synthesize dispatch missed). Transport
1038    /// errors and non-2xx-non-404 statuses surface as
1039    /// [`RunnerTransportError`].
1040    pub async fn system_popup_action(
1041        &self,
1042        popup_id: &str,
1043        button_id: &str,
1044    ) -> Result<bool, RunnerTransportError> {
1045        let url = self.url("/system-popup-action", None);
1046        let body = SystemPopupActionRequest {
1047            popup_id: popup_id.to_string(),
1048            button_id: button_id.to_string(),
1049        };
1050        let res = self
1051            .send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
1052            .await?;
1053        let status = res.status();
1054        if status.as_u16() == 404 {
1055            return Ok(false);
1056        }
1057        if !status.is_success() {
1058            let body = res.text().await.unwrap_or_default();
1059            return Err(RunnerTransportError::NonSuccessStatus {
1060                endpoint: "/system-popup-action".to_string(),
1061                status: status.as_u16(),
1062                body: body.chars().take(200).collect(),
1063            });
1064        }
1065        let resp: SystemPopupActionResponse =
1066            res.json()
1067                .await
1068                .map_err(|source| RunnerTransportError::NonJsonBody {
1069                    endpoint: "/system-popup-action".to_string(),
1070                    source,
1071                })?;
1072        Ok(resp.ok)
1073    }
1074
1075    /// `POST /tap` — selector → element tap. Errors:
1076    /// - 404 → [`TapNotFoundError`] returned via `Err(.. Other ..)`. We
1077    ///   keep the simple `RunnerTransportError + body inspection`
1078    ///   pattern. Driver layer wraps with ExpectationFailure.
1079    pub async fn tap(
1080        &self,
1081        selector: &Selector,
1082        mode: TapMode,
1083        include: Option<IncludeScope>,
1084    ) -> Result<TapResult, RunnerTransportError> {
1085        #[derive(Serialize)]
1086        struct Req<'a> {
1087            selector: &'a Selector,
1088            mode: TapMode,
1089        }
1090        self.json_post("/tap", &Req { selector, mode }, include)
1091            .await
1092    }
1093
1094    /// `POST /tap-at-norm-coord` — Apple native UI event coord tap.
1095    pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), RunnerTransportError> {
1096        #[derive(Serialize)]
1097        struct Req {
1098            nx: f64,
1099            ny: f64,
1100        }
1101        let _: serde_json::Value = self
1102            .json_post("/tap-at-norm-coord", &Req { nx, ny }, None)
1103            .await?;
1104        Ok(())
1105    }
1106
1107    /// `POST /double-tap-at-norm-coord` — double-tap at
1108    /// viewport-normalized coord. Android-specific endpoint backing
1109    /// `AndroidDriver::double_tap` after host-resolve. iOS path uses
1110    /// selector-based `/double-tap`; this exists for Android where the
1111    /// runner does its own host-resolve via tree dump.
1112    pub async fn double_tap_at_norm_coord(
1113        &self,
1114        nx: f64,
1115        ny: f64,
1116    ) -> Result<(), RunnerTransportError> {
1117        #[derive(Serialize)]
1118        struct Req {
1119            nx: f64,
1120            ny: f64,
1121        }
1122        let _: serde_json::Value = self
1123            .json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
1124            .await?;
1125        Ok(())
1126    }
1127
1128    /// `POST /long-press-at-norm-coord` — long-press at coord
1129    /// with explicit duration. Android-specific.
1130    pub async fn long_press_at_norm_coord(
1131        &self,
1132        nx: f64,
1133        ny: f64,
1134        duration_ms: u64,
1135    ) -> Result<(), RunnerTransportError> {
1136        #[derive(Serialize)]
1137        struct Req {
1138            nx: f64,
1139            ny: f64,
1140            #[serde(rename = "durationMs")]
1141            duration_ms: u64,
1142        }
1143        let _: serde_json::Value = self
1144            .json_post(
1145                "/long-press-at-norm-coord",
1146                &Req {
1147                    nx,
1148                    ny,
1149                    duration_ms,
1150                },
1151                None,
1152            )
1153            .await?;
1154        Ok(())
1155    }
1156
1157    /// `POST /input-text` — type text into currently-focused
1158    /// input. Caller must tap to focus the field first (AndroidDriver
1159    /// orchestrates). Android-specific.
1160    pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
1161        #[derive(Serialize)]
1162        struct Req<'a> {
1163            text: &'a str,
1164        }
1165        let _: serde_json::Value = self.json_post("/input-text", &Req { text }, None).await?;
1166        Ok(())
1167    }
1168
1169    /// `POST /tap-by-id` — `XCUIElement.tap()` via the XCTest
1170    /// gesture-recognizer chain. Drives SwiftUI `.sheet` / `.alert` /
1171    /// `.confirmationDialog` / `.fullScreenCover` dismiss bindings that the
1172    /// default host-HID-at-coord path can't trigger. Returns
1173    /// `Ok(true)` when the element was found and tapped, `Ok(false)` when
1174    /// the runner reported `ok=false` (element not found / NSException
1175    /// surfaced through `smixGuarded`). Parallel to
1176    /// `tap_at_norm_coord`; the existing `/tap` envelope stays untouched.
1177    pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
1178        #[derive(Serialize)]
1179        struct Req<'a> {
1180            id: &'a str,
1181        }
1182        #[derive(Deserialize)]
1183        struct Resp {
1184            ok: bool,
1185        }
1186        let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
1187        Ok(resp.ok)
1188    }
1189
1190    /// `POST /find-text-by-ocr` — Apple Vision OCR over the
1191    /// current XCUIScreen screenshot. Returns the matching text
1192    /// observation's bounding box normalized to `[0, 1]` in UIKit coord
1193    /// space (top-left origin, y-down). `Ok(None)` when no observation
1194    /// matches the keyword. Sense layer covering "lib without testID
1195    /// but with visible text" scenarios.
1196    ///
1197    /// `locales` are BCP-47 language subtags (e.g. `["en"]` / `["ja"]`);
1198    /// empty defaults to `["en"]` on the swift side. `recognition_level`
1199    /// is `"accurate"` (default, slower + higher accuracy) or `"fast"`.
1200    pub async fn find_text_by_ocr(
1201        &self,
1202        text: &str,
1203        locales: &[String],
1204        recognition_level: &str,
1205    ) -> Result<Option<OcrFrame>, RunnerTransportError> {
1206        #[derive(Serialize)]
1207        struct Req<'a> {
1208            text: &'a str,
1209            locales: &'a [String],
1210            recognition_level: &'a str,
1211        }
1212        #[derive(Deserialize)]
1213        struct Resp {
1214            found: bool,
1215            frame: Option<[f64; 4]>,
1216        }
1217        let resp: Resp = self
1218            .json_post(
1219                "/find-text-by-ocr",
1220                &Req {
1221                    text,
1222                    locales,
1223                    recognition_level,
1224                },
1225                None,
1226            )
1227            .await?;
1228        if !resp.found {
1229            return Ok(None);
1230        }
1231        match resp.frame {
1232            Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
1233            None => Ok(None),
1234        }
1235    }
1236
1237    /// Eval JS against app-side WKWebView bridge. Does NOT use the
1238    /// XCUITest runner — POSTs directly to the app-side debug bridge on
1239    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback). Returns
1240    /// the JS eval result as JSON Value or runtime error from the bridge.
1241    ///
1242    /// **Scope**: works for any app that exposes the
1243    /// `SmixWebViewBridge`. Bridge port is fixed at 28080 today.
1244    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
1245        #[derive(Serialize)]
1246        struct Req<'a> {
1247            js: &'a str,
1248        }
1249        #[derive(Deserialize)]
1250        struct Resp {
1251            result: serde_json::Value,
1252            error: String,
1253        }
1254        let url = "http://127.0.0.1:28080/eval";
1255        let resp = reqwest::Client::new()
1256            .post(url)
1257            .json(&Req { js })
1258            .send()
1259            .await
1260            .map_err(|e| RunnerTransportError::Unreachable {
1261                endpoint: "webview-bridge".into(),
1262                message: format!("webview-bridge POST: {e}"),
1263            })?;
1264        let status = resp.status();
1265        let body: Resp = resp
1266            .json()
1267            .await
1268            .map_err(|e| RunnerTransportError::Unreachable {
1269                endpoint: "webview-bridge".into(),
1270                message: format!("webview-bridge JSON decode (status {status}): {e}"),
1271            })?;
1272        if !body.error.is_empty() {
1273            return Err(RunnerTransportError::Unreachable {
1274                endpoint: "webview-bridge".into(),
1275                message: format!("webview-bridge JS error: {}", body.error),
1276            });
1277        }
1278        Ok(body.result)
1279    }
1280
1281    /// `POST /double-tap` — XCUIElement.doubleTap() public API.
1282    /// Mirrors `/tap` envelope but issues two-tap gesture on the resolved
1283    /// element. Same as Maestro `doubleTapOn`.
1284    pub async fn double_tap(
1285        &self,
1286        selector: &Selector,
1287        include: Option<IncludeScope>,
1288    ) -> Result<TapResult, RunnerTransportError> {
1289        #[derive(Serialize)]
1290        struct Req<'a> {
1291            selector: &'a Selector,
1292        }
1293        self.json_post("/double-tap", &Req { selector }, include)
1294            .await
1295    }
1296
1297    /// `POST /long-press` — XCUIElement.press(forDuration:) public
1298    /// API. `duration_ms` comes from maestro yaml `duration:`, default 500.
1299    /// Same as Maestro `longPressOn`.
1300    pub async fn long_press(
1301        &self,
1302        selector: &Selector,
1303        duration_ms: u64,
1304        include: Option<IncludeScope>,
1305    ) -> Result<TapResult, RunnerTransportError> {
1306        #[derive(Serialize)]
1307        struct Req<'a> {
1308            selector: &'a Selector,
1309            #[serde(rename = "durationMs")]
1310            duration_ms: u64,
1311        }
1312        self.json_post(
1313            "/long-press",
1314            &Req {
1315                selector,
1316                duration_ms,
1317            },
1318            include,
1319        )
1320        .await
1321    }
1322
1323    /// `POST /set-orientation` — rotate sim via swift
1324    /// `XCUIDevice.shared.orientation` setter. `orientation` literal:
1325    /// "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight".
1326    pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
1327        #[derive(Serialize)]
1328        struct Req<'a> {
1329            orientation: &'a str,
1330        }
1331        let _: serde_json::Value = self
1332            .json_post("/set-orientation", &Req { orientation }, None)
1333            .await?;
1334        Ok(())
1335    }
1336
1337    /// `POST /swipe-at-norm-coord` — Apple native UI event from-to
1338    /// swipe. Sibling of [`Self::tap_at_norm_coord`] under §9 #3 escape hatch.
1339    pub async fn swipe_at_norm_coord(
1340        &self,
1341        from: (f64, f64),
1342        to: (f64, f64),
1343    ) -> Result<(), RunnerTransportError> {
1344        #[derive(Serialize)]
1345        struct Req {
1346            #[serde(rename = "fromNx")]
1347            from_nx: f64,
1348            #[serde(rename = "fromNy")]
1349            from_ny: f64,
1350            #[serde(rename = "toNx")]
1351            to_nx: f64,
1352            #[serde(rename = "toNy")]
1353            to_ny: f64,
1354        }
1355        let _: serde_json::Value = self
1356            .json_post(
1357                "/swipe-at-norm-coord",
1358                &Req {
1359                    from_nx: from.0,
1360                    from_ny: from.1,
1361                    to_nx: to.0,
1362                    to_ny: to.1,
1363                },
1364                None,
1365            )
1366            .await?;
1367        Ok(())
1368    }
1369
1370    /// `POST /find` — boolean existence quick-probe.
1371    pub async fn find(
1372        &self,
1373        selector: &Selector,
1374        include: Option<IncludeScope>,
1375    ) -> Result<bool, RunnerTransportError> {
1376        #[derive(Serialize)]
1377        struct Req<'a> {
1378            selector: &'a Selector,
1379        }
1380        #[derive(Deserialize)]
1381        struct Resp {
1382            /// Wire field name. iOS SmixRunner emits `found`.
1383            #[serde(default)]
1384            found: bool,
1385            /// Legacy alias — historical wire used `exists`.
1386            #[serde(default)]
1387            exists: bool,
1388        }
1389        // Previously read `exists || ok`, but `ok` is the transport-
1390        // level "runner responded 200" flag that is TRUE for both
1391        // found and not-found. That short-circuited the `find`
1392        // predicate to always-true, defeating when-visible logic.
1393        // SmixRunner emits `found`; historical wire had `exists`.
1394        // Accept either; do NOT fall back to `ok`.
1395        let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
1396        Ok(r.found || r.exists)
1397    }
1398
1399    /// `POST /fill` — fill text into focused / matched input.
1400    pub async fn fill(
1401        &self,
1402        selector: &Selector,
1403        text: &str,
1404        include: Option<IncludeScope>,
1405    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1406        #[derive(Serialize)]
1407        struct Req<'a> {
1408            selector: &'a Selector,
1409            text: &'a str,
1410        }
1411        self.json_post("/fill", &Req { selector, text }, include)
1412            .await
1413    }
1414
1415    /// `POST /clear` — clear focused / matched input.
1416    pub async fn clear(
1417        &self,
1418        selector: &Selector,
1419        include: Option<IncludeScope>,
1420    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1421        #[derive(Serialize)]
1422        struct Req<'a> {
1423            selector: &'a Selector,
1424        }
1425        self.json_post("/clear", &Req { selector }, include).await
1426    }
1427
1428    /// `POST /press-key` — press named key (Return/Tab/Delete/etc.).
1429    pub async fn press_key(
1430        &self,
1431        key: KeyName,
1432    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1433        #[derive(Serialize)]
1434        struct Req {
1435            key: KeyName,
1436        }
1437        self.json_post("/press-key", &Req { key }, None).await
1438    }
1439
1440    /// `POST /scroll {selector, direction, include?}` — scroll-until-visible.
1441    pub async fn scroll(
1442        &self,
1443        selector: &RunnerScrollSelector,
1444        direction: SwipeDirection,
1445        include: Option<IncludeScope>,
1446    ) -> Result<u32, RunnerTransportError> {
1447        #[derive(Serialize)]
1448        struct Req<'a> {
1449            selector: &'a RunnerScrollSelector,
1450            direction: SwipeDirection,
1451        }
1452        #[derive(Deserialize)]
1453        struct Resp {
1454            #[serde(default)]
1455            matched: Option<bool>,
1456            #[serde(default)]
1457            swipes: Option<u32>,
1458        }
1459        let r: Resp = self
1460            .json_post(
1461                "/scroll",
1462                &Req {
1463                    selector,
1464                    direction,
1465                },
1466                include,
1467            )
1468            .await?;
1469        let matched = r.matched.unwrap_or(false);
1470        let swipes = r.swipes.unwrap_or(0);
1471        if !matched {
1472            // Driver layer is responsible for converting matched:false
1473            // to ExpectationFailure(ELEMENT_NOT_FOUND); here we surface
1474            // via MalformedBody when shape is missing entirely, otherwise
1475            // return the swipe count for the caller to inspect.
1476            //
1477            // We return swipes; driver wraps via RunnerScrollNotMatched.
1478            return Err(RunnerTransportError::MalformedBody {
1479                endpoint: "/scroll".into(),
1480                detail: format!("not_matched after {swipes} swipes"),
1481            });
1482        }
1483        Ok(swipes)
1484    }
1485
1486    /// `POST /swipe-once {direction}` — single swipe, no probe.
1487    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
1488        #[derive(Serialize)]
1489        struct Req {
1490            direction: SwipeDirection,
1491        }
1492        let _: serde_json::Value = self
1493            .json_post("/swipe-once", &Req { direction }, None)
1494            .await?;
1495        Ok(())
1496    }
1497
1498    /// `POST /foreground {bundleId}` — bring app to foreground.
1499    pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
1500        #[derive(Serialize)]
1501        struct Req<'a> {
1502            #[serde(rename = "bundleId")]
1503            bundle_id: &'a str,
1504        }
1505        let _: serde_json::Value = self
1506            .json_post("/foreground", &Req { bundle_id }, None)
1507            .await?;
1508        Ok(())
1509    }
1510
1511    /// `POST /hide-keyboard`.
1512    pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
1513        let _: serde_json::Value = self
1514            .json_post("/hide-keyboard", &serde_json::json!({}), None)
1515            .await?;
1516        Ok(())
1517    }
1518
1519    /// `POST /back` — back gesture.
1520    pub async fn back(&self) -> Result<(), RunnerTransportError> {
1521        let _: serde_json::Value = self
1522            .json_post("/back", &serde_json::json!({}), None)
1523            .await?;
1524        Ok(())
1525    }
1526
1527    // ---- recorder ----
1528
1529    /// `POST /record/start` — begin recording.
1530    pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
1531        let _: serde_json::Value = self
1532            .json_post("/record/start", &serde_json::json!({}), None)
1533            .await?;
1534        Ok(())
1535    }
1536
1537    /// `GET /record/poll` — drain recorded events.
1538    pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1539        #[derive(Deserialize)]
1540        struct Envelope {
1541            events: Vec<RecordedEvent>,
1542        }
1543        let env: Envelope = self.json_get("/record/poll", None).await?;
1544        Ok(env.events)
1545    }
1546
1547    /// `POST /record/stop` — stop recording, drain final events.
1548    pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1549        #[derive(Deserialize)]
1550        struct Envelope {
1551            events: Vec<RecordedEvent>,
1552        }
1553        let env: Envelope = self
1554            .json_post("/record/stop", &serde_json::json!({}), None)
1555            .await?;
1556        Ok(env.events)
1557    }
1558}