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