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//! Design invariant: no raw URL / sleep / xpath surface is ever exposed.
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 answered HTTP 200 with `{"ok":false}` — the handler
71    /// ran and reports the action failed (element synthesis failed,
72    /// unknown orientation, in-handler exception fallback, …). Eleven
73    /// act routes used to discard the body as `serde_json::Value`, so
74    /// this signal was invisible: a tap whose terminal synthesis failed
75    /// reported success all the way up.
76    #[error("runner {endpoint} answered ok:false — the action did not happen")]
77    Refused { endpoint: String },
78    /// The runner returned
79    /// `{"ok":false,"error":"snapshot_unavailable"}` (or the enriched
80    /// body with `target`/`reason` fields). This is not a network
81    /// failure — it means XCUITest could not snapshot the app the client
82    /// asked about. Callers that see this after all retries can surface
83    /// an AI-readable hint about foreground state.
84    #[error(
85        "runner {endpoint}: app unavailable (target={target:?}, reason={reason:?}, category={category:?}, hint={hint:?})"
86    )]
87    AppUnavailable {
88        endpoint: String,
89        target: Option<String>,
90        /// Legacy free-form `reason` string, kept for wire
91        /// compatibility with runners that predate `category`.
92        reason: Option<String>,
93        /// Categorized enum from the runner's tree-unavailable
94        /// envelope. `None` when the runner is too old to emit it;
95        /// string values are the kebab-case identifiers in
96        /// `AppUnavailableReason`: `crashed-during-init`,
97        /// `alive-but-tree-empty`, `alive-but-tree-stale`,
98        /// `driver-disconnected`, `unknown`.
99        category: Option<String>,
100        /// Actionable text steering downstream tooling. `None` when
101        /// the runner is too old to emit it.
102        hint: Option<String>,
103    },
104    /// Client-side rolling-window observation: consecutive requests
105    /// have been failing (5xx, unreachable, non-JSON). The runner is
106    /// still responding to the transport, but its answers are unsound
107    /// — surfacing this instead of silently returning stale bodies is
108    /// the whole point of [`HttpRunnerClient::with_liveness_window`].
109    #[error(
110        "runner degraded: {non_success_recent}/{window} recent requests failed \
111         (last_endpoint={last_endpoint}, last_error={last_error})"
112    )]
113    RunnerDegraded {
114        window: usize,
115        non_success_recent: usize,
116        last_endpoint: String,
117        last_error: String,
118    },
119    /// Client-side observation of a hard runner death. Triggered when a
120    /// `is_connect()` error fires AND a `/health` probe times out or
121    /// returns a non-2xx within 1 s. Distinct from
122    /// [`RunnerTransportError::Unreachable`] which fires on the initial
123    /// reachability probe; `RunnerDied` fires mid-session after
124    /// `ensure_reachable` had already succeeded.
125    #[error("runner died mid-session: last_seen_ms={last_seen_ms}, last_error={last_error}")]
126    RunnerDied {
127        last_seen_ms: u64,
128        last_error: String,
129    },
130}
131
132/// Client-side rolling window tracking the last N request outcomes.
133/// Used by `with_liveness_window` to distinguish "runner is alive and
134/// healthily returning some errors" from "runner has silently drifted
135/// into a degraded state". See [`RunnerTransportError::RunnerDegraded`]
136/// and [`RunnerTransportError::RunnerDied`].
137#[derive(Debug, Clone)]
138pub(crate) struct LivenessState {
139    window: usize,
140    /// True = success, false = non-success. Front = oldest.
141    outcomes: std::collections::VecDeque<bool>,
142    last_endpoint: String,
143    last_error: String,
144    /// Milliseconds since UNIX epoch of the last successful request.
145    last_seen_ms: u64,
146    /// True once we've fired a RunnerDied. Prevents re-firing until a
147    /// fresh success clears it.
148    died: bool,
149}
150
151impl LivenessState {
152    fn with_window(window: usize) -> Self {
153        Self {
154            window: window.max(2),
155            outcomes: std::collections::VecDeque::with_capacity(window.max(2)),
156            last_endpoint: String::new(),
157            last_error: String::new(),
158            last_seen_ms: 0,
159            died: false,
160        }
161    }
162
163    fn record(&mut self, endpoint: &str, success: bool, error: &str) {
164        while self.outcomes.len() >= self.window {
165            self.outcomes.pop_front();
166        }
167        self.outcomes.push_back(success);
168        self.last_endpoint = endpoint.to_string();
169        if success {
170            self.last_error.clear();
171            self.last_seen_ms = std::time::SystemTime::now()
172                .duration_since(std::time::UNIX_EPOCH)
173                .map(|d| d.as_millis() as u64)
174                .unwrap_or(0);
175            self.died = false;
176        } else {
177            self.last_error = error.to_string();
178        }
179    }
180
181    /// If the last `window` outcomes have a majority-non-success shape,
182    /// return the degraded error variant. Caller wraps into the outer
183    /// [`RunnerTransportError`].
184    fn degraded(&self) -> Option<RunnerTransportError> {
185        if self.outcomes.len() < self.window {
186            return None;
187        }
188        let bad = self.outcomes.iter().filter(|ok| !**ok).count();
189        if bad * 2 > self.window {
190            Some(RunnerTransportError::RunnerDegraded {
191                window: self.window,
192                non_success_recent: bad,
193                last_endpoint: self.last_endpoint.clone(),
194                last_error: self.last_error.clone(),
195            })
196        } else {
197            None
198        }
199    }
200}
201
202/// Per-request context carried across the wire via the
203/// `App-Bundle-Id` and `App-Activate` HTTP headers. Client sets
204/// these via `HttpRunnerClient::with_target_bundle_id` /
205/// `with_auto_activate`, or per-call by cloning the client with a
206/// modified context. The runner side reads them into
207/// `SmixRunnerServer.currentContext` (task-local) and passes to
208/// `resolveApp()` in every app-touching handler.
209///
210/// The two-field split is intentional: `bundle_id` is "which app do I
211/// want to talk to", `activate` is "should we bring it foreground first".
212#[derive(Clone, Debug, Default)]
213pub struct RequestContext {
214    pub bundle_id: Option<String>,
215    pub activate: bool,
216}
217
218/// `POST /tap` returned `404 / not_found` — element matched no node.
219/// Mirrors TS `TapNotFoundError`.
220#[derive(Debug, Error)]
221#[error("tap not_found for selector {selector_json}: {body}")]
222pub struct TapNotFoundError {
223    pub selector_json: String,
224    pub body: String,
225}
226
227/// `POST /scroll` returned `200 / matched:false / swipes:N` — scroll
228/// exhausted N swipes without surfacing the target. Mirrors TS
229/// `RunnerScrollNotMatched`.
230#[derive(Debug, Error)]
231#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
232pub struct RunnerScrollNotMatched {
233    pub selector_json: String,
234    pub swipes: u32,
235}
236
237/// `POST /swipe-once` returned `200 / ok:false / vanished_during_swipe`.
238/// Mirrors TS `RunnerSwipeOnceFailure`.
239#[derive(Debug, Error)]
240#[error("swipeOnce failed: {detail}")]
241pub struct RunnerSwipeOnceFailure {
242    pub detail: String,
243}
244
245/// Normalized OCR bounding box returned by
246/// [`HttpRunnerClient::find_text_by_ocr`]. Coordinates are normalized to
247/// `[0, 1]` in UIKit coord space (top-left origin, y-down). Apple Vision's
248/// native bbox is bottom-left origin + y-up — the swift handler converts
249/// before returning so all consumers see UIKit coords.
250#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
251pub struct OcrFrame {
252    /// Top-left x in `[0, 1]`.
253    pub nx: f64,
254    /// Top-left y in `[0, 1]`.
255    pub ny: f64,
256    /// Width in `[0, 1]`.
257    pub w: f64,
258    /// Height in `[0, 1]`.
259    pub h: f64,
260}
261
262impl OcrFrame {
263    /// Center x in `[0, 1]`.
264    #[must_use]
265    pub fn mid_x(&self) -> f64 {
266        self.nx + self.w * 0.5
267    }
268    /// Center y in `[0, 1]`.
269    #[must_use]
270    pub fn mid_y(&self) -> f64 {
271        self.ny + self.h * 0.5
272    }
273}
274
275// -------------------- Wire types ----------------------------------------
276//
277// Wire types live in the smix-runner-wire stone. The HTTP client
278// (this crate) re-exports them so existing consumers don't break.
279// Anyone needing the wire shapes without the reqwest+tokio HTTP impl
280// should depend on smix-runner-wire directly.
281
282pub use smix_runner_wire::{
283    DiagnosticDumpResponse, FindRequest, FindResponse, HealthProcessInfo, HealthResponse,
284    HealthTestHostInfo, IncludeScope, KeyboardStages, PressResult, RecordEventsResponse,
285    RecordedEvent, RunnerIncludeOpts, RunnerKeyboardResult, RunnerScrollSelector, ScrollResponse,
286    SessionAppLifecycleRequest, SessionAppLifecycleResponse, SessionCloseAllResponse,
287    SessionCloseRequest, SessionCloseResponse, SessionListResponse, SessionOpenRequest,
288    SessionOpenResponse, SessionRelaunchAppRequest, SessionRelaunchAppResponse,
289    SessionRenewActivationRequest, SessionRenewActivationResponse, SessionSummary,
290    SimHealthWireState, SubprocessRecord as WireSubprocessRecord, SystemPopup,
291    SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
292    TapAtCoordResult, TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
293};
294
295// -------------------- Transport retry constants -------------------------
296
297/// Total transport attempts (1 initial + 2 retries) for transient reqwest
298/// connection errors. See `send_with_retry` doc.
299const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
300/// Backoff between transport retry attempts.
301const TRANSPORT_BACKOFF_MS: u64 = 100;
302
303/// Classify a non-2xx runner response body. When the body
304/// carries `snapshot_unavailable` (bare shape) or the enriched
305/// `{"ok":false,"error":"snapshot_unavailable","target":"...","reason":"..."}`
306/// shape, lift it to a first-class
307/// `RunnerTransportError::AppUnavailable` variant. Otherwise fall
308/// through to the generic `NonSuccessStatus`.
309fn classify_error_body(endpoint: &str, status: u16, body: &str) -> RunnerTransportError {
310    if body.contains("snapshot_unavailable") {
311        // Parse enriched body when present. Any parse miss falls back to
312        // just the marker string — still surfaces useful hint upstream.
313        #[derive(Deserialize)]
314        struct Unavailable {
315            #[serde(default)]
316            target: Option<String>,
317            #[serde(default)]
318            reason: Option<String>,
319            #[serde(default)]
320            hint: Option<String>,
321        }
322        let parsed: Option<Unavailable> = serde_json::from_str(body).ok();
323        // The wire's `reason` field carries the kebab-case category
324        // enum on newer runners and a free-form string on older ones.
325        // Discriminate by matching against the known category values;
326        // anything else falls through to the legacy `reason` slot.
327        let parsed_reason = parsed.as_ref().and_then(|p| p.reason.clone());
328        let (category, legacy_reason) = match parsed_reason.as_deref() {
329            Some(
330                "crashed-during-init"
331                | "alive-but-tree-empty"
332                | "alive-but-tree-stale"
333                | "driver-disconnected"
334                | "unknown",
335            ) => (parsed_reason.clone(), parsed_reason),
336            _ => (None, parsed_reason),
337        };
338        return RunnerTransportError::AppUnavailable {
339            endpoint: endpoint.to_string(),
340            target: parsed.as_ref().and_then(|p| p.target.clone()),
341            reason: legacy_reason,
342            category,
343            hint: parsed.as_ref().and_then(|p| p.hint.clone()),
344        };
345    }
346    RunnerTransportError::NonSuccessStatus {
347        endpoint: endpoint.to_string(),
348        status,
349        body: body.chars().take(200).collect(),
350    }
351}
352
353// -------------------- Client --------------------------------------------
354
355/// HTTP client to the swift-side SmixRunnerCore. Async (tokio-based).
356/// Constructed once per Cell; `ensure_reachable` memoizes a successful
357/// `/health` probe so subsequent calls don't re-probe.
358/// A burst of one is an ordinary tap, and says so by leaving the field
359/// off the wire entirely.
360fn is_one(n: &u32) -> bool {
361    *n == 1
362}
363
364#[derive(Debug)]
365pub struct HttpRunnerClient {
366    base: String,
367    client: reqwest::Client,
368    reachable: Arc<AtomicBool>,
369    /// Target bundle id, sent as `App-Bundle-Id` header on every
370    /// request. `None` = client doesn't say; runner uses its
371    /// boot-time default `app`.
372    target_bundle_id: Option<String>,
373    /// If `true`, sends `App-Activate: true` on every request so the
374    /// runner calls `.activate()` on the resolved target before
375    /// operating. Auto-recovers from cases where XCUITest's implicit
376    /// app-under-test binds to the wrong foreground.
377    auto_activate: bool,
378    /// Sent as `Input-Dispatch-Mode` header on every request.
379    /// `None` = no header sent (runner uses default).
380    input_dispatch_mode: Option<InputDispatchMode>,
381    /// Sent as `Session-Id` header on every request. Set by
382    /// `Session::wrap` after `open_session()`. When present, the
383    /// runner short-circuits its per-request `resolveApp()` and reuses
384    /// the session's cached `XCUIApplication` binding — this is what
385    /// eliminates the activation storm on long-running gates.
386    session_id: Option<String>,
387    /// Rolling-window state for liveness observability. `None` when
388    /// disabled (default). See [`Self::with_liveness_window`].
389    liveness: Arc<std::sync::Mutex<Option<LivenessState>>>,
390    /// Sim-side health monitor. `None` when disabled (default). See
391    /// [`Self::with_sim_health`]. When present, `/health` outcomes
392    /// feed `SimHealthMonitor::record_health_ok` / `_fail`, so a
393    /// subscriber gets `Degraded` / `Dead` transitions without polling.
394    sim_health: Option<smix_sim_health::SimHealthMonitor>,
395    /// Session state atomic shared with the SDK Session.
396    /// Updated on every response by parsing the `X-Sim-Health` header
397    /// (values `healthy` | `degraded` | `cycling` | `dead`). Unknown /
398    /// missing header leaves the state unchanged.
399    session_state: Arc<std::sync::Mutex<Option<Arc<std::sync::atomic::AtomicU8>>>>,
400    /// Port of the in-app `SmixWebViewBridge` that `webview_eval`
401    /// reaches on iOS. Read once from `SMIX_WEBVIEW_BRIDGE_PORT` at
402    /// construction; see [`with_webview_bridge_port`] to set it
403    /// directly.
404    ///
405    /// [`with_webview_bridge_port`]: Self::with_webview_bridge_port
406    webview_bridge_port: u16,
407}
408
409/// Port the iOS in-app webview bridge listens on when nothing says
410/// otherwise.
411pub const DEFAULT_WEBVIEW_BRIDGE_PORT: u16 = 28080;
412
413/// Resolve the bridge port from a raw string, falling back to the
414/// default when absent or unparseable.
415///
416/// Takes the value rather than reading the environment so it can be
417/// tested without mutating process state — a suite that sets env vars
418/// to exercise them cannot be run in parallel with itself.
419///
420/// Falling back rather than erroring matches `runner_port_from_env`,
421/// which SMIX_RUNNER_PORT has always used. Same convention, stated
422/// rather than reinvented.
423#[must_use]
424pub fn webview_bridge_port_from(raw: Option<&str>) -> u16 {
425    raw.and_then(|s| s.trim().parse::<u16>().ok())
426        .unwrap_or(DEFAULT_WEBVIEW_BRIDGE_PORT)
427}
428
429/// The bridge endpoint for a given port.
430///
431/// The host stays 127.0.0.1: the simulator shares the host loopback,
432/// which is why this route can reach an in-app server at all. That is a
433/// decision on record, not an oversight to parameterise.
434#[must_use]
435pub fn webview_bridge_url(port: u16) -> String {
436    format!("http://127.0.0.1:{port}/eval")
437}
438
439/// Outcome of probing a live runner for the in-process soft-cycle
440/// (`POST /soft-cycle` + a `GET /health` re-confirmation after the
441/// FlyingFox bounce).
442///
443/// The soft-cycle keeps the xcodebuild/XCUITest host alive and bounces
444/// the in-process server + rebinds the app, which is why it recovers a
445/// reachable runner in seconds instead of the ~36 s SIGINT-teardown +
446/// respawn a hard cycle pays. Only a runner that is alive AND answering
447/// `/health` can be soft-cycled; everything else routes to the hard
448/// fallback.
449#[derive(Clone, Debug, PartialEq, Eq)]
450pub enum SoftCycleProbe {
451    /// `/soft-cycle` returned 200 and `/health` answered again after the
452    /// bounce — the runner recovered in-process. Carries the CLI-observed
453    /// warm recovery wall-time in ms.
454    Recovered {
455        /// Warm recovery wall-time, ms (POST start → `/health` 200 again).
456        wall_ms: u64,
457    },
458    /// `/health` never answered — the host is dead or wedged, so there is
459    /// no in-process channel to soft-cycle through.
460    Unreachable,
461    /// The runner answered but does not know `/soft-cycle` (a runner that
462    /// shipped before v2.8-C2).
463    Unsupported,
464    /// `/soft-cycle` was reached but the bounce did not complete (the
465    /// server never came back, or the route errored).
466    Failed(String),
467}
468
469/// The action `smix runner cycle` takes given a [`SoftCycleProbe`].
470#[derive(Clone, Debug, PartialEq, Eq)]
471pub enum CyclePlan {
472    /// The in-process soft-cycle recovered the runner; nothing else to
473    /// do. Carries the measured warm recovery wall-time (ms).
474    Soft {
475        /// Warm recovery wall-time, ms.
476        wall_ms: u64,
477    },
478    /// Fall back to the xcodebuild hard-cycle (`down()` + `up()`),
479    /// byte-identical to the pre-C2 behaviour. Carries a human reason.
480    HardFallback {
481        /// Why the soft-cycle was not taken.
482        reason: String,
483    },
484}
485
486/// Map a soft-cycle probe to the cycle action. Pure — the network work
487/// happens in the probe; this only chooses the branch, so the recoverable
488/// / unrecoverable split is checkable device-free.
489#[must_use]
490pub fn soft_cycle_plan(probe: SoftCycleProbe) -> CyclePlan {
491    match probe {
492        SoftCycleProbe::Recovered { wall_ms } => CyclePlan::Soft { wall_ms },
493        SoftCycleProbe::Unreachable => CyclePlan::HardFallback {
494            reason: "runner /health unreachable — host dead or wedged".to_string(),
495        },
496        SoftCycleProbe::Unsupported => CyclePlan::HardFallback {
497            reason: "runner does not support /soft-cycle (older runner)".to_string(),
498        },
499        SoftCycleProbe::Failed(detail) => CyclePlan::HardFallback {
500            reason: format!("soft-cycle did not complete: {detail}"),
501        },
502    }
503}
504
505/// Input dispatch mode for the `Input-Dispatch-Mode` header.
506/// Runner-side interpretation lives in `SmixRunnerCore/FillRoute.swift`.
507#[derive(Clone, Copy, Debug, PartialEq, Eq)]
508pub enum InputDispatchMode {
509    /// a11y-anchored dispatch (default; XCUIElement.typeText).
510    A11y,
511    /// Raw key events via IOHID / daemon; skip a11y-focus resolution.
512    /// Covers the RN hidden-input case where a11y-focus lookup returns
513    /// nothing.
514    KeyEvents,
515    /// Try a11y first; on ElementNotFound fall back to key-events.
516    Auto,
517}
518
519impl InputDispatchMode {
520    fn header_value(self) -> &'static str {
521        match self {
522            InputDispatchMode::A11y => "a11y",
523            InputDispatchMode::KeyEvents => "key-events",
524            InputDispatchMode::Auto => "auto",
525        }
526    }
527}
528
529/// 200-with-`{"ok":bool}` envelope shared by the act routes. Absent
530/// `ok` passes (shapes that never carried it stay compatible); explicit
531/// `false` is a refusal the caller must see.
532#[derive(Deserialize)]
533struct OkEnvelope {
534    #[serde(default)]
535    ok: Option<bool>,
536}
537
538impl OkEnvelope {
539    fn require_ok(self, endpoint: &str) -> Result<(), RunnerTransportError> {
540        if self.ok == Some(false) {
541            return Err(RunnerTransportError::Refused {
542                endpoint: endpoint.to_string(),
543            });
544        }
545        Ok(())
546    }
547}
548
549impl HttpRunnerClient {
550    /// Construct a client targeting `http://127.0.0.1:{port}`.
551    pub fn new(port: u16) -> Self {
552        Self::with_base(format!("http://127.0.0.1:{port}"))
553    }
554
555    /// Construct with an explicit base URL (test / non-localhost cases).
556    pub fn with_base<S: Into<String>>(base: S) -> Self {
557        let client = reqwest::Client::builder()
558            .timeout(Duration::from_secs(15))
559            .build()
560            .expect("reqwest::Client::builder default never fails");
561        HttpRunnerClient {
562            base: base.into(),
563            client,
564            reachable: Arc::new(AtomicBool::new(false)),
565            target_bundle_id: None,
566            auto_activate: false,
567            input_dispatch_mode: None,
568            session_id: None,
569            liveness: Arc::new(std::sync::Mutex::new(None)),
570            sim_health: None,
571            session_state: Arc::new(std::sync::Mutex::new(None)),
572            webview_bridge_port: webview_bridge_port_from(
573                std::env::var("SMIX_WEBVIEW_BRIDGE_PORT").ok().as_deref(),
574            ),
575        }
576    }
577
578    /// Point `webview_eval` at a different in-app bridge port.
579    #[must_use]
580    pub fn with_webview_bridge_port(mut self, port: u16) -> Self {
581        self.webview_bridge_port = port;
582        self
583    }
584
585    /// Attach the SDK Session's state atomic so the
586    /// client updates it on every response by parsing `X-Sim-Health`.
587    /// Called by `App::open_session`. External callers rarely need
588    /// this directly.
589    pub fn attach_session_state(&self, state: Arc<std::sync::atomic::AtomicU8>) {
590        let mut guard = self
591            .session_state
592            .lock()
593            .expect("session_state mutex must not be poisoned");
594        *guard = Some(state);
595    }
596
597    fn update_session_state_from_header(&self, header: Option<&str>) {
598        let Some(value) = header else {
599            return;
600        };
601        let Some(state) = smix_runner_wire::SimHealthWireState::from_header(value) else {
602            return;
603        };
604        let byte = match state {
605            smix_runner_wire::SimHealthWireState::Healthy => 0u8,
606            smix_runner_wire::SimHealthWireState::Degraded => 1u8,
607            smix_runner_wire::SimHealthWireState::Cycling => 2u8,
608            smix_runner_wire::SimHealthWireState::Dead => 3u8,
609            _ => return,
610        };
611        let guard = self
612            .session_state
613            .lock()
614            .expect("session_state mutex must not be poisoned");
615        if let Some(atomic) = guard.as_ref() {
616            atomic.store(byte, std::sync::atomic::Ordering::Release);
617        }
618    }
619
620    /// Set the target bundle id sent as `App-Bundle-Id` header on
621    /// every subsequent request. Runner rebinds
622    /// `XCUIApplication(bundleIdentifier:)` per request if this differs
623    /// from its boot-time default.
624    #[must_use]
625    pub fn with_target_bundle_id<S: Into<String>>(mut self, bundle: S) -> Self {
626        self.target_bundle_id = Some(bundle.into());
627        self
628    }
629
630    /// Mutable variant of [`Self::with_target_bundle_id`]. Used from
631    /// the driver-trait pass-through where the driver holds the client
632    /// by value and can't easily use the builder shape.
633    pub fn set_target_bundle_id<S: Into<String>>(&mut self, bundle: S) {
634        self.target_bundle_id = Some(bundle.into());
635    }
636
637    /// When set, every subsequent request sends `App-Activate: true`,
638    /// causing the runner to `.activate()` the resolved target before
639    /// operating.
640    #[must_use]
641    pub fn with_auto_activate(mut self, activate: bool) -> Self {
642        self.auto_activate = activate;
643        self
644    }
645
646    /// Mutable variant of [`Self::with_auto_activate`].
647    pub fn set_auto_activate(&mut self, activate: bool) {
648        self.auto_activate = activate;
649    }
650
651    /// Set the input dispatch mode header.
652    /// Sent as `Input-Dispatch-Mode: <mode>` on every request that
653    /// touches text input (currently `/fill` + `/clear`). Runner routes:
654    /// - `a11y` (default) — resolve focus via a11y tree, dispatch to
655    ///   XCUIElement.typeText
656    /// - `key-events` — skip a11y-focus resolution, send raw key events
657    ///   via IOHID / daemon
658    /// - `auto` — try a11y first, fall back to key-events on
659    ///   ElementNotFound
660    ///
661    /// `--force-key-events` on `smix run` sets this to `key-events`.
662    #[must_use]
663    pub fn with_input_dispatch_mode(mut self, mode: InputDispatchMode) -> Self {
664        self.input_dispatch_mode = Some(mode);
665        self
666    }
667
668    /// Mutable variant.
669    pub fn set_input_dispatch_mode(&mut self, mode: InputDispatchMode) {
670        self.input_dispatch_mode = Some(mode);
671    }
672
673    /// Accessor for the current target bundle. Used by callers
674    /// that want to log or diagnose.
675    pub fn target_bundle_id(&self) -> Option<&str> {
676        self.target_bundle_id.as_deref()
677    }
678
679    /// Accessor for the current auto-activate flag.
680    pub fn auto_activate(&self) -> bool {
681        self.auto_activate
682    }
683
684    /// Attach a session id sent as the `Session-Id` header on every
685    /// subsequent request. When the runner sees this header it looks up
686    /// the session's cached `XCUIApplication` and skips the per-request
687    /// `.activate()`, eliminating the activation storm on long-running
688    /// gates. Typically not called directly — use `open_session()` +
689    /// a session wrapper instead.
690    pub fn set_session_id<S: Into<String>>(&mut self, id: S) {
691        self.session_id = Some(id.into());
692    }
693
694    /// Clear a previously-set session id, reverting to legacy per-request
695    /// `resolveApp()` (now itself rate-limited to at most one `.activate()`
696    /// per 5 s per bundle-id).
697    pub fn clear_session_id(&mut self) {
698        self.session_id = None;
699    }
700
701    /// The session id in force for outgoing requests, or `None` if the
702    /// legacy per-request rebind path is in use.
703    pub fn session_id(&self) -> Option<&str> {
704        self.session_id.as_deref()
705    }
706
707    /// Enable liveness observability. The client tracks the last `window`
708    /// request outcomes; if a majority are non-success (5xx, unreachable,
709    /// connection-refused), subsequent calls surface
710    /// [`RunnerTransportError::RunnerDegraded`] instead of returning
711    /// silent stale data. On any `is_connect()` failure the client also
712    /// probes `/health`; if that fails within 1 s the next call surfaces
713    /// [`RunnerTransportError::RunnerDied`].
714    ///
715    /// Idempotent; safe to call more than once (last window size wins).
716    /// Disabled by default.
717    #[must_use]
718    pub fn with_liveness_window(self, window: usize) -> Self {
719        {
720            let mut guard = self
721                .liveness
722                .lock()
723                .expect("liveness mutex must not be poisoned");
724            *guard = Some(LivenessState::with_window(window));
725        }
726        self
727    }
728
729    /// Attach a [`smix_sim_health::SimHealthMonitor`] that receives
730    /// `/health` outcome observations. Every `health()`, `health_detail()`
731    /// and internal `probe_death()` call feeds
732    /// `SimHealthMonitor::record_health_ok` / `record_health_fail`, and
733    /// subscribers get a `Degraded` / `Dead` transition without polling.
734    ///
735    /// Additive over `with_liveness_window`; both may be enabled at the
736    /// same time — the liveness window handles per-call transport
737    /// classification, the sim-health monitor aggregates across the
738    /// wider observation surface (screenshot wall time, watched
739    /// processes) fed by other crates.
740    ///
741    /// Since smix 1.0.4.
742    #[must_use]
743    pub fn with_sim_health(mut self, monitor: smix_sim_health::SimHealthMonitor) -> Self {
744        self.sim_health = Some(monitor);
745        self
746    }
747
748    /// Mutable variant of [`Self::with_sim_health`].
749    pub fn set_sim_health(&mut self, monitor: smix_sim_health::SimHealthMonitor) {
750        self.sim_health = Some(monitor);
751    }
752
753    /// Accessor for the attached sim-health monitor, or `None` if the
754    /// caller opted out. Used by driver / SDK layers that need to
755    /// subscribe to state transitions or read the current classification.
756    pub fn sim_health(&self) -> Option<&smix_sim_health::SimHealthMonitor> {
757        self.sim_health.as_ref()
758    }
759
760    /// Apply per-request context headers to a reqwest builder.
761    /// Every method that constructs a request calls this so the
762    /// `App-Bundle-Id` / `App-Activate` / `Session-Id` /
763    /// `Input-Dispatch-Mode` headers are sent uniformly.
764    fn apply_context(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
765        let mut b = builder;
766        if let Some(bundle) = &self.target_bundle_id {
767            b = b.header("App-Bundle-Id", bundle);
768        }
769        if self.auto_activate {
770            b = b.header("App-Activate", "true");
771        }
772        if let Some(mode) = self.input_dispatch_mode {
773            b = b.header("Input-Dispatch-Mode", mode.header_value());
774        }
775        if let Some(sid) = &self.session_id {
776            b = b.header("Session-Id", sid);
777        }
778        b
779    }
780
781    /// Base URL accessor.
782    pub fn base(&self) -> &str {
783        &self.base
784    }
785
786    // ---- low-level helpers ----
787
788    fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
789        match include {
790            Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
791            None => format!("{}{}", self.base, endpoint),
792        }
793    }
794
795    /// Wire-level transport retry for transient reqwest connection
796    /// errors (`is_connect()` / `is_request()` — pre-server-receipt failures
797    /// raised before any bytes leave the local socket, idempotent-safe for
798    /// every route). Server-side `5xx` / `NonJsonBody` are NOT retried here:
799    /// those are semantic responses, retried (or not) by callers like
800    /// `driver.find`'s 500 retry loop or `driver.wait_for`'s selector poll.
801    ///
802    /// Budget: 3 attempts total (1 initial + 2 retries) × `TRANSPORT_BACKOFF_MS`
803    /// between attempts. Catches sim/runner socket hiccups mid-flow
804    /// without inflating happy-path latency: retries only fire on actual failure.
805    async fn send_with_retry<F>(
806        &self,
807        endpoint: &str,
808        builder_fn: F,
809    ) -> Result<reqwest::Response, RunnerTransportError>
810    where
811        F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
812    {
813        // Preflight: if liveness is enabled and previously observed the
814        // runner died, short-circuit until the caller resets it (via a
815        // successful direct `/health`).
816        if let Some(err) = self.preflight_liveness() {
817            return Err(err);
818        }
819        let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
820        loop {
821            attempts_left -= 1;
822            match builder_fn(&self.client).send().await {
823                Ok(res) => {
824                    // Parse the `X-Sim-Health` response header
825                    // and propagate to the attached Session state atomic
826                    // (if any). Absent header leaves state unchanged.
827                    let sim_health_hdr = res
828                        .headers()
829                        .get("X-Sim-Health")
830                        .and_then(|v| v.to_str().ok())
831                        .map(|s| s.to_string());
832                    self.update_session_state_from_header(sim_health_hdr.as_deref());
833                    // Record but don't classify yet — status-level handling
834                    // happens in the json_* helpers so the "5xx but recovered
835                    // via retry" case is still counted as success.
836                    return Ok(res);
837                }
838                Err(e) => {
839                    let retryable = e.is_connect() || e.is_request();
840                    if !retryable || attempts_left == 0 {
841                        let is_connect = e.is_connect();
842                        let err_str = format!("{e}");
843                        self.record_outcome(endpoint, false, &err_str);
844                        if is_connect && let Some(died) = self.probe_death(endpoint, &err_str).await
845                        {
846                            return Err(died);
847                        }
848                        return Err(RunnerTransportError::FetchFailed {
849                            endpoint: endpoint.to_string(),
850                            source: e,
851                        });
852                    }
853                    tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
854                }
855            }
856        }
857    }
858
859    fn record_outcome(&self, endpoint: &str, success: bool, err: &str) {
860        let mut guard = self
861            .liveness
862            .lock()
863            .expect("liveness mutex must not be poisoned");
864        if let Some(state) = guard.as_mut() {
865            state.record(endpoint, success, err);
866        }
867    }
868
869    fn preflight_liveness(&self) -> Option<RunnerTransportError> {
870        let guard = self
871            .liveness
872            .lock()
873            .expect("liveness mutex must not be poisoned");
874        if let Some(state) = guard.as_ref() {
875            if state.died {
876                return Some(RunnerTransportError::RunnerDied {
877                    last_seen_ms: state.last_seen_ms,
878                    last_error: state.last_error.clone(),
879                });
880            }
881            return state.degraded();
882        }
883        None
884    }
885
886    async fn probe_death(&self, endpoint: &str, err_str: &str) -> Option<RunnerTransportError> {
887        // Snapshot `last_seen_ms` before probing so the caller can
888        // report "last successful contact at X".
889        let last_seen_ms = {
890            let guard = self
891                .liveness
892                .lock()
893                .expect("liveness mutex must not be poisoned");
894            guard.as_ref().map(|s| s.last_seen_ms).unwrap_or(0)
895        };
896        let url = format!("{}/health", self.base);
897        let alive = tokio::time::timeout(Duration::from_millis(1000), self.client.get(&url).send())
898            .await
899            .ok()
900            .and_then(|r| r.ok())
901            .map(|r| r.status().is_success())
902            .unwrap_or(false);
903        if alive {
904            return None;
905        }
906        {
907            let mut guard = self
908                .liveness
909                .lock()
910                .expect("liveness mutex must not be poisoned");
911            if let Some(state) = guard.as_mut() {
912                state.died = true;
913                state.last_endpoint = endpoint.to_string();
914                state.last_error = err_str.to_string();
915            }
916        }
917        // "Died mid-session" is only true of a runner we ever saw
918        // alive. last_seen_ms == 0 means no request has EVER succeeded —
919        // the honest story there is "not reachable; is the runner up?",
920        // and telling a first-time user their runner "died" sent them
921        // hunting for a crash that never happened.
922        if last_seen_ms == 0 {
923            return Some(RunnerTransportError::Unreachable {
924                endpoint: endpoint.to_string(),
925                message: format!(
926                    "SmixRunner never answered at {} — start it with `smix runner up` ({err_str})",
927                    self.base
928                ),
929            });
930        }
931        Some(RunnerTransportError::RunnerDied {
932            last_seen_ms,
933            last_error: err_str.to_string(),
934        })
935    }
936
937    async fn json_get<T: for<'de> Deserialize<'de>>(
938        &self,
939        endpoint: &str,
940        include: Option<IncludeScope>,
941    ) -> Result<T, RunnerTransportError> {
942        let url = self.url(endpoint, include);
943        let res = self
944            .send_with_retry(endpoint, |c| self.apply_context(c.get(&url)))
945            .await?;
946        let status = res.status();
947        if !status.is_success() {
948            let body = res.text().await.unwrap_or_default();
949            let err = classify_error_body(endpoint, status.as_u16(), &body);
950            self.record_outcome(endpoint, false, &err.to_string());
951            return Err(err);
952        }
953        match res.json::<T>().await {
954            Ok(v) => {
955                self.record_outcome(endpoint, true, "");
956                Ok(v)
957            }
958            Err(source) => {
959                let err = RunnerTransportError::NonJsonBody {
960                    endpoint: endpoint.to_string(),
961                    source,
962                };
963                self.record_outcome(endpoint, false, &err.to_string());
964                Err(err)
965            }
966        }
967    }
968
969    async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
970        &self,
971        endpoint: &str,
972        body: &B,
973        include: Option<IncludeScope>,
974    ) -> Result<T, RunnerTransportError> {
975        let url = self.url(endpoint, include);
976        let res = self
977            .send_with_retry(endpoint, |c| self.apply_context(c.post(&url).json(body)))
978            .await?;
979        let status = res.status();
980        if !status.is_success() {
981            let body = res.text().await.unwrap_or_default();
982            let err = classify_error_body(endpoint, status.as_u16(), &body);
983            self.record_outcome(endpoint, false, &err.to_string());
984            return Err(err);
985        }
986        match res.json::<T>().await {
987            Ok(v) => {
988                self.record_outcome(endpoint, true, "");
989                Ok(v)
990            }
991            Err(source) => {
992                let err = RunnerTransportError::NonJsonBody {
993                    endpoint: endpoint.to_string(),
994                    source,
995                };
996                self.record_outcome(endpoint, false, &err.to_string());
997                Err(err)
998            }
999        }
1000    }
1001
1002    // ---- public methods (mirrors TS RunnerClient surface) ----
1003
1004    /// `GET /health` — bare alive probe (no memoization). Returns true on 2xx.
1005    pub async fn health(&self) -> bool {
1006        let url = self.url("/health", None);
1007        let ok = match self.client.get(&url).send().await {
1008            Ok(res) => res.status().is_success(),
1009            Err(_) => false,
1010        };
1011        if let Some(monitor) = &self.sim_health {
1012            if ok {
1013                monitor.record_health_ok();
1014            } else {
1015                monitor.record_health_fail();
1016            }
1017        }
1018        ok
1019    }
1020
1021    /// `GET /health` memoized — first call probes; subsequent are no-ops
1022    /// after one success. Failure raises [`RunnerTransportError::Unreachable`].
1023    /// Mirrors TS `ensureReachable`.
1024    pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
1025        if self.reachable.load(Ordering::Acquire) {
1026            return Ok(());
1027        }
1028        if self.health().await {
1029            self.reachable.store(true, Ordering::Release);
1030            return Ok(());
1031        }
1032        Err(RunnerTransportError::Unreachable {
1033            endpoint: "/health".into(),
1034            message: format!("SmixRunner not reachable at {}", self.base),
1035        })
1036    }
1037
1038    /// `GET /health` extended — parses the JSON body carrying uptime /
1039    /// activation counters / open-session count. Legacy runners return a
1040    /// bare 200 with no body; for those this returns a default
1041    /// [`smix_runner_wire::HealthResponse`] with only `ok = true` set.
1042    pub async fn health_detail(
1043        &self,
1044    ) -> Result<smix_runner_wire::HealthResponse, RunnerTransportError> {
1045        let url = self.url("/health", None);
1046        let res = self.client.get(&url).send().await.map_err(|source| {
1047            if let Some(monitor) = &self.sim_health {
1048                monitor.record_health_fail();
1049            }
1050            RunnerTransportError::FetchFailed {
1051                endpoint: "/health".into(),
1052                source,
1053            }
1054        })?;
1055        let status = res.status();
1056        if !status.is_success() {
1057            if let Some(monitor) = &self.sim_health {
1058                monitor.record_health_fail();
1059            }
1060            let body = res.text().await.unwrap_or_default();
1061            return Err(RunnerTransportError::NonSuccessStatus {
1062                endpoint: "/health".into(),
1063                status: status.as_u16(),
1064                body,
1065            });
1066        }
1067        if let Some(monitor) = &self.sim_health {
1068            monitor.record_health_ok();
1069        }
1070        // Legacy runners return an empty body; treat parse
1071        // failure as {ok: true} to keep the "health passed" invariant.
1072        let text = res.text().await.unwrap_or_default();
1073        if text.trim().is_empty() {
1074            return Ok(smix_runner_wire::HealthResponse {
1075                ok: true,
1076                ..Default::default()
1077            });
1078        }
1079        Ok(
1080            serde_json::from_str(&text).unwrap_or(smix_runner_wire::HealthResponse {
1081                ok: true,
1082                ..Default::default()
1083            }),
1084        )
1085    }
1086
1087    /// `POST /session/open` — reserve a runner-side XCUIApplication
1088    /// binding and (optionally) activate it once. The returned session
1089    /// id becomes the `Session-Id` header on subsequent requests via
1090    /// [`Self::set_session_id`] or a session wrapper.
1091    pub async fn open_session(
1092        &self,
1093        req: &smix_runner_wire::SessionOpenRequest,
1094    ) -> Result<smix_runner_wire::SessionOpenResponse, RunnerTransportError> {
1095        self.json_post("/session/open", req, None).await
1096    }
1097
1098    /// `POST /session/close` — release a previously-opened session.
1099    /// Idempotent; closing an unknown session returns `ok = true`.
1100    pub async fn close_session(
1101        &self,
1102        req: &smix_runner_wire::SessionCloseRequest,
1103    ) -> Result<smix_runner_wire::SessionCloseResponse, RunnerTransportError> {
1104        self.json_post("/session/close", req, None).await
1105    }
1106
1107    /// `POST /session/renew-activation` — re-issue `.activate()` on the
1108    /// session's cached binding, subject to the runner-side per-session
1109    /// 5 s rate limit. Escape hatch for consumers that detect drift.
1110    pub async fn renew_session_activation(
1111        &self,
1112        req: &smix_runner_wire::SessionRenewActivationRequest,
1113    ) -> Result<smix_runner_wire::SessionRenewActivationResponse, RunnerTransportError> {
1114        self.json_post("/session/renew-activation", req, None).await
1115    }
1116
1117    /// `POST /session/close-all` — clear every open
1118    /// session on the runner. Used by `smix runner cycle` and the
1119    /// runner-side supervisor to drop stale bindings after a cycle.
1120    /// Idempotent — closing an empty session table returns
1121    /// `{ok:true, closed:0}`.
1122    pub async fn close_all_sessions(
1123        &self,
1124    ) -> Result<smix_runner_wire::SessionCloseAllResponse, RunnerTransportError> {
1125        // No request body — send an empty JSON object.
1126        self.json_post("/session/close-all", &serde_json::json!({}), None)
1127            .await
1128    }
1129
1130    /// `POST /session/relaunch-app` — instruct the runner
1131    /// to `terminate()` + `launch()` the session's cached
1132    /// `XCUIApplication` binding IN PLACE. Preserves session id and
1133    /// XCUITest binding; recovers from a downstream app crash without
1134    /// cycling the runner.
1135    pub async fn relaunch_session_app(
1136        &self,
1137        req: &smix_runner_wire::SessionRelaunchAppRequest,
1138    ) -> Result<smix_runner_wire::SessionRelaunchAppResponse, RunnerTransportError> {
1139        self.json_post("/session/relaunch-app", req, None).await
1140    }
1141
1142    /// `POST /session/list` — enumerate every session
1143    /// currently known to the runner. Useful for diagnostics + the
1144    /// supervisor + SDK `Session::still_valid()` probe after a cycle.
1145    pub async fn list_sessions(
1146        &self,
1147    ) -> Result<smix_runner_wire::SessionListResponse, RunnerTransportError> {
1148        self.json_post("/session/list", &serde_json::json!({}), None)
1149            .await
1150    }
1151
1152    /// `POST /session/terminate-app` — cooperative
1153    /// `XCUIApplication.terminate()` on the session's cached binding
1154    /// via testmanagerd (NOT `simctl terminate` SIGKILL). Does not
1155    /// signal `com.apple.ReportCrash`. Paired with
1156    /// [`Self::launch_session_app`] and a host-side
1157    /// `SimctlClient::clear_app_sandbox` invocation to implement the
1158    /// `Session::reset_app_data` orchestration that eliminates the
1159    /// app's "quit unexpectedly" system dialog.
1160    pub async fn terminate_session_app(
1161        &self,
1162        req: &smix_runner_wire::SessionAppLifecycleRequest,
1163    ) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
1164        self.json_post("/session/terminate-app", req, None).await
1165    }
1166
1167    /// `POST /session/launch-app` — cooperative
1168    /// `XCUIApplication.launch()` on the session's cached binding.
1169    /// Companion of [`Self::terminate_session_app`].
1170    pub async fn launch_session_app(
1171        &self,
1172        req: &smix_runner_wire::SessionAppLifecycleRequest,
1173    ) -> Result<smix_runner_wire::SessionAppLifecycleResponse, RunnerTransportError> {
1174        self.json_post("/session/launch-app", req, None).await
1175    }
1176
1177    /// `POST /diagnostic/dump` — one-shot post-mortem
1178    /// snapshot of the runner's runtime state. Bundles the recent
1179    /// subprocess ring buffer, open sessions, sim-health state,
1180    /// supervisor pid, and uptime. `smix diagnostic dump` calls this
1181    /// then pretty-prints.
1182    ///
1183    /// Legacy runners return 404; consumers can
1184    /// gracefully fall back to the client-side ring buffer only.
1185    pub async fn diagnostic_dump(
1186        &self,
1187    ) -> Result<smix_runner_wire::DiagnosticDumpResponse, RunnerTransportError> {
1188        self.json_post("/diagnostic/dump", &serde_json::json!({}), None)
1189            .await
1190    }
1191
1192    /// `GET /tree?include=` — full a11y tree dump.
1193    ///
1194    /// Post-deser pass [`derive_roles_recursive`] fills
1195    /// `A11yNode.role` from `raw_type` because the Swift `/tree` route
1196    /// only emits `rawType` on the wire. Without this, `Selector::Role`
1197    /// would never match real-sim payloads.
1198    pub async fn get_tree(
1199        &self,
1200        include: Option<IncludeScope>,
1201    ) -> Result<A11yNode, RunnerTransportError> {
1202        let mut tree: A11yNode = self.json_get("/tree", include).await?;
1203        derive_roles_recursive(&mut tree);
1204        Ok(tree)
1205    }
1206
1207    /// `GET /system-popups?include=` — list system popups.
1208    pub async fn system_popups(
1209        &self,
1210        include: Option<IncludeScope>,
1211    ) -> Result<Vec<SystemPopup>, RunnerTransportError> {
1212        #[derive(Deserialize)]
1213        struct Envelope {
1214            popups: Vec<SystemPopup>,
1215        }
1216        let env: Envelope = self.json_get("/system-popups", include).await?;
1217        Ok(env.popups)
1218    }
1219
1220    /// `POST /system-popup-action`. Tap a button
1221    /// on a previously enumerated popup. `popup_id` and `button_id` are
1222    /// the `Popup.id` / `PopupButton.id` strings returned by
1223    /// [`Self::system_popups`]; the runner walks the same scan order so
1224    /// the round-trip does not need a host-side id map.
1225    ///
1226    /// Returns `Ok(true)` when the runner matched both ids and dispatched
1227    /// the tap, `Ok(false)` when the runner returned a 404 not_found
1228    /// (popup id, button id, or synthesize dispatch missed). Transport
1229    /// errors and non-2xx-non-404 statuses surface as
1230    /// [`RunnerTransportError`].
1231    pub async fn system_popup_action(
1232        &self,
1233        popup_id: &str,
1234        button_id: &str,
1235    ) -> Result<bool, RunnerTransportError> {
1236        let url = self.url("/system-popup-action", None);
1237        let body = SystemPopupActionRequest {
1238            popup_id: popup_id.to_string(),
1239            button_id: button_id.to_string(),
1240        };
1241        let res = self
1242            .send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
1243            .await?;
1244        let status = res.status();
1245        if status.as_u16() == 404 {
1246            return Ok(false);
1247        }
1248        if !status.is_success() {
1249            let body = res.text().await.unwrap_or_default();
1250            return Err(RunnerTransportError::NonSuccessStatus {
1251                endpoint: "/system-popup-action".to_string(),
1252                status: status.as_u16(),
1253                body: body.chars().take(200).collect(),
1254            });
1255        }
1256        let resp: SystemPopupActionResponse =
1257            res.json()
1258                .await
1259                .map_err(|source| RunnerTransportError::NonJsonBody {
1260                    endpoint: "/system-popup-action".to_string(),
1261                    source,
1262                })?;
1263        Ok(resp.ok)
1264    }
1265
1266    /// `POST /tap` — selector → element tap. Errors:
1267    /// - 404 → [`TapNotFoundError`] returned via `Err(.. Other ..)`. We
1268    ///   keep the simple `RunnerTransportError + body inspection`
1269    ///   pattern. Driver layer wraps with ExpectationFailure.
1270    pub async fn tap(
1271        &self,
1272        selector: &Selector,
1273        mode: TapMode,
1274        include: Option<IncludeScope>,
1275    ) -> Result<TapResult, RunnerTransportError> {
1276        #[derive(Serialize)]
1277        struct Req<'a> {
1278            selector: &'a Selector,
1279            mode: TapMode,
1280        }
1281        self.json_post("/tap", &Req { selector, mode }, include)
1282            .await
1283    }
1284
1285    /// `POST /tap-at-norm-coord` — Apple native UI event coord tap.
1286    /// `POST /tap-at-norm-coord` — the default tap path.
1287    ///
1288    /// Returns what the point turned out to be inside. It used to
1289    /// return nothing at all, so a caller could report "tapped" having
1290    /// checked only that a touch was synthesised somewhere.
1291    ///
1292    /// A runner older than the `chain` field answers without it and
1293    /// deserializes to an empty chain — indistinguishable on the wire
1294    /// from a point that landed outside everything, which is why the
1295    /// host treats an empty chain as its own verdict rather than as a
1296    /// pass.
1297    pub async fn tap_at_norm_coord(
1298        &self,
1299        nx: f64,
1300        ny: f64,
1301    ) -> Result<TapAtCoordResult, RunnerTransportError> {
1302        self.tap_at_norm_coord_burst(nx, ny, 1, None, None).await
1303    }
1304
1305    /// `POST /tap-at-norm-coord` with several touches at one point.
1306    ///
1307    /// One request, one synthesise, `times` touches spaced by
1308    /// `interval_ms` on the event timeline. Sending them one at a time
1309    /// costs a round trip each — measured at ~400 ms on iOS 26.5 — and
1310    /// leaves the spacing as whatever that round trip happened to be,
1311    /// which is why a gesture gated on a 500 ms inter-tap window could
1312    /// not be driven: a flow could not tell a slow harness from a
1313    /// broken app.
1314    ///
1315    /// `None` for either timing takes the runner's default.
1316    pub async fn tap_at_norm_coord_burst(
1317        &self,
1318        nx: f64,
1319        ny: f64,
1320        times: u32,
1321        interval_ms: Option<u32>,
1322        hold_ms: Option<u32>,
1323    ) -> Result<TapAtCoordResult, RunnerTransportError> {
1324        #[derive(Serialize)]
1325        #[serde(rename_all = "camelCase")]
1326        struct Req {
1327            nx: f64,
1328            ny: f64,
1329            // Omitted for an ordinary tap, so the common case puts
1330            // exactly the bytes on the wire it always did — including
1331            // for a runner that has never heard of a burst.
1332            #[serde(skip_serializing_if = "is_one")]
1333            times: u32,
1334            #[serde(skip_serializing_if = "Option::is_none")]
1335            interval_ms: Option<u32>,
1336            #[serde(skip_serializing_if = "Option::is_none")]
1337            hold_ms: Option<u32>,
1338        }
1339        #[derive(Deserialize)]
1340        struct Resp {
1341            #[serde(default)]
1342            ok: Option<bool>,
1343            #[serde(flatten)]
1344            result: TapAtCoordResult,
1345        }
1346        let body: Resp = self
1347            .json_post(
1348                "/tap-at-norm-coord",
1349                &Req {
1350                    nx,
1351                    ny,
1352                    times,
1353                    interval_ms,
1354                    hold_ms,
1355                },
1356                None,
1357            )
1358            .await?;
1359        OkEnvelope { ok: body.ok }.require_ok("/tap-at-norm-coord")?;
1360        Ok(body.result)
1361    }
1362
1363    /// `POST /double-tap-at-norm-coord` — double-tap at
1364    /// viewport-normalized coord. Android-specific endpoint backing
1365    /// `AndroidDriver::double_tap` after host-resolve. iOS path uses
1366    /// selector-based `/double-tap`; this exists for Android where the
1367    /// runner does its own host-resolve via tree dump.
1368    pub async fn double_tap_at_norm_coord(
1369        &self,
1370        nx: f64,
1371        ny: f64,
1372    ) -> Result<(), RunnerTransportError> {
1373        #[derive(Serialize)]
1374        struct Req {
1375            nx: f64,
1376            ny: f64,
1377        }
1378        let body: OkEnvelope = self
1379            .json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
1380            .await?;
1381        body.require_ok("/double-tap-at-norm-coord")?;
1382        Ok(())
1383    }
1384
1385    /// `POST /long-press-at-norm-coord` — long-press at coord
1386    /// with explicit duration. Android-specific.
1387    pub async fn long_press_at_norm_coord(
1388        &self,
1389        nx: f64,
1390        ny: f64,
1391        duration_ms: u64,
1392    ) -> Result<(), RunnerTransportError> {
1393        #[derive(Serialize)]
1394        struct Req {
1395            nx: f64,
1396            ny: f64,
1397            #[serde(rename = "durationMs")]
1398            duration_ms: u64,
1399        }
1400        let body: OkEnvelope = self
1401            .json_post(
1402                "/long-press-at-norm-coord",
1403                &Req {
1404                    nx,
1405                    ny,
1406                    duration_ms,
1407                },
1408                None,
1409            )
1410            .await?;
1411        body.require_ok("/long-press-at-norm-coord")?;
1412        Ok(())
1413    }
1414
1415    /// `POST /input-text` — type text into currently-focused
1416    /// input. Caller must tap to focus the field first (AndroidDriver
1417    /// orchestrates). Android-specific.
1418    pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
1419        #[derive(Serialize)]
1420        struct Req<'a> {
1421            text: &'a str,
1422        }
1423        let body: OkEnvelope = self.json_post("/input-text", &Req { text }, None).await?;
1424        body.require_ok("/input-text")?;
1425        Ok(())
1426    }
1427
1428    /// `POST /tap-by-id` — `XCUIElement.tap()` via the XCTest
1429    /// gesture-recognizer chain. Drives SwiftUI `.sheet` / `.alert` /
1430    /// `.confirmationDialog` / `.fullScreenCover` dismiss bindings that the
1431    /// default host-HID-at-coord path can't trigger. Returns
1432    /// `Ok(true)` when the element was found and tapped, `Ok(false)` when
1433    /// the runner reported `ok=false` (element not found / NSException
1434    /// surfaced through `smixGuarded`). Parallel to
1435    /// `tap_at_norm_coord`; the existing `/tap` envelope stays untouched.
1436    pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
1437        #[derive(Serialize)]
1438        struct Req<'a> {
1439            id: &'a str,
1440        }
1441        #[derive(Deserialize)]
1442        struct Resp {
1443            ok: bool,
1444        }
1445        let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
1446        Ok(resp.ok)
1447    }
1448
1449    /// `POST /find-text-by-ocr` — Apple Vision OCR over the
1450    /// current XCUIScreen screenshot. Returns the matching text
1451    /// observation's bounding box normalized to `[0, 1]` in UIKit coord
1452    /// space (top-left origin, y-down). `Ok(None)` when no observation
1453    /// matches the keyword. Sense layer covering "lib without testID
1454    /// but with visible text" scenarios.
1455    ///
1456    /// `locales` are BCP-47 language subtags (e.g. `["en"]` / `["ja"]`);
1457    /// empty defaults to `["en"]` on the swift side. `recognition_level`
1458    /// is `"accurate"` (default, slower + higher accuracy) or `"fast"`.
1459    pub async fn find_text_by_ocr(
1460        &self,
1461        text: &str,
1462        locales: &[String],
1463        recognition_level: &str,
1464    ) -> Result<Option<OcrFrame>, RunnerTransportError> {
1465        #[derive(Serialize)]
1466        struct Req<'a> {
1467            text: &'a str,
1468            locales: &'a [String],
1469            recognition_level: &'a str,
1470        }
1471        #[derive(Deserialize)]
1472        struct Resp {
1473            found: bool,
1474            frame: Option<[f64; 4]>,
1475        }
1476        let resp: Resp = self
1477            .json_post(
1478                "/find-text-by-ocr",
1479                &Req {
1480                    text,
1481                    locales,
1482                    recognition_level,
1483                },
1484                None,
1485            )
1486            .await?;
1487        if !resp.found {
1488            return Ok(None);
1489        }
1490        match resp.frame {
1491            Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
1492            None => Ok(None),
1493        }
1494    }
1495
1496    /// Eval JS against app-side WKWebView bridge. Does NOT use the
1497    /// XCUITest runner — POSTs directly to the app-side debug bridge on
1498    /// the host loopback the simulator shares. Returns the JS eval
1499    /// result as JSON Value or runtime error from the bridge.
1500    ///
1501    /// **Scope**: works for any app that exposes the
1502    /// `SmixWebViewBridge`. The port defaults to
1503    /// [`DEFAULT_WEBVIEW_BRIDGE_PORT`] and is overridable via
1504    /// `SMIX_WEBVIEW_BRIDGE_PORT` or
1505    /// [`with_webview_bridge_port`]; it used to be a literal, which
1506    /// meant a bridge on any other port was unreachable.
1507    ///
1508    /// [`with_webview_bridge_port`]: Self::with_webview_bridge_port
1509    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
1510        #[derive(Serialize)]
1511        struct Req<'a> {
1512            js: &'a str,
1513        }
1514        #[derive(Deserialize)]
1515        struct Resp {
1516            result: serde_json::Value,
1517            error: String,
1518        }
1519        let url = webview_bridge_url(self.webview_bridge_port);
1520        let resp = reqwest::Client::new()
1521            .post(url)
1522            .json(&Req { js })
1523            .send()
1524            .await
1525            .map_err(|e| RunnerTransportError::Unreachable {
1526                endpoint: "webview-bridge".into(),
1527                message: format!("webview-bridge POST: {e}"),
1528            })?;
1529        let status = resp.status();
1530        let body: Resp = resp
1531            .json()
1532            .await
1533            .map_err(|e| RunnerTransportError::Unreachable {
1534                endpoint: "webview-bridge".into(),
1535                message: format!("webview-bridge JSON decode (status {status}): {e}"),
1536            })?;
1537        if !body.error.is_empty() {
1538            return Err(RunnerTransportError::Unreachable {
1539                endpoint: "webview-bridge".into(),
1540                message: format!("webview-bridge JS error: {}", body.error),
1541            });
1542        }
1543        Ok(body.result)
1544    }
1545
1546    /// Eval JS via the RUNNER's `/webview-eval` proxy (Android: the
1547    /// Kotlin runner forwards to the app's shim on :28081, because the
1548    /// emulator's loopback is not the host's). Same `{js}` request and
1549    /// `{result, error}` response contract as the direct iOS bridge.
1550    pub async fn webview_eval_via_runner(
1551        &self,
1552        js: &str,
1553    ) -> Result<serde_json::Value, RunnerTransportError> {
1554        #[derive(Serialize)]
1555        struct Req<'a> {
1556            js: &'a str,
1557        }
1558        #[derive(Deserialize)]
1559        struct Resp {
1560            #[serde(default)]
1561            result: serde_json::Value,
1562            #[serde(default)]
1563            error: String,
1564        }
1565        let resp: Resp = self.json_post("/webview-eval", &Req { js }, None).await?;
1566        if !resp.error.is_empty() {
1567            return Err(RunnerTransportError::Refused {
1568                endpoint: "/webview-eval".to_string(),
1569            });
1570        }
1571        Ok(resp.result)
1572    }
1573
1574    /// `POST /double-tap` — XCUIElement.doubleTap() public API.
1575    /// Mirrors `/tap` envelope but issues two-tap gesture on the resolved
1576    /// element. Same as Maestro `doubleTapOn`.
1577    pub async fn double_tap(
1578        &self,
1579        selector: &Selector,
1580        include: Option<IncludeScope>,
1581    ) -> Result<TapResult, RunnerTransportError> {
1582        #[derive(Serialize)]
1583        struct Req<'a> {
1584            selector: &'a Selector,
1585        }
1586        self.json_post("/double-tap", &Req { selector }, include)
1587            .await
1588    }
1589
1590    /// `POST /long-press` — XCUIElement.press(forDuration:) public
1591    /// API. `duration_ms` comes from maestro yaml `duration:`, default 500.
1592    /// Same as Maestro `longPressOn`.
1593    pub async fn long_press(
1594        &self,
1595        selector: &Selector,
1596        duration_ms: u64,
1597        include: Option<IncludeScope>,
1598    ) -> Result<PressResult, RunnerTransportError> {
1599        #[derive(Serialize)]
1600        struct Req<'a> {
1601            selector: &'a Selector,
1602            #[serde(rename = "durationMs")]
1603            duration_ms: u64,
1604        }
1605        self.json_post(
1606            "/long-press",
1607            &Req {
1608                selector,
1609                duration_ms,
1610            },
1611            include,
1612        )
1613        .await
1614    }
1615
1616    /// `POST /set-orientation` — rotate sim via swift
1617    /// `XCUIDevice.shared.orientation` setter. `orientation` literal:
1618    /// "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight".
1619    pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
1620        #[derive(Serialize)]
1621        struct Req<'a> {
1622            orientation: &'a str,
1623        }
1624        let body: OkEnvelope = self
1625            .json_post("/set-orientation", &Req { orientation }, None)
1626            .await?;
1627        body.require_ok("/set-orientation")?;
1628        Ok(())
1629    }
1630
1631    /// `POST /swipe-at-norm-coord` — Apple native UI event from-to
1632    /// swipe. Sibling of [`Self::tap_at_norm_coord`] under the same
1633    /// normalized-coordinate escape hatch.
1634    pub async fn swipe_at_norm_coord(
1635        &self,
1636        from: (f64, f64),
1637        to: (f64, f64),
1638    ) -> Result<(), RunnerTransportError> {
1639        #[derive(Serialize)]
1640        struct Req {
1641            #[serde(rename = "fromNx")]
1642            from_nx: f64,
1643            #[serde(rename = "fromNy")]
1644            from_ny: f64,
1645            #[serde(rename = "toNx")]
1646            to_nx: f64,
1647            #[serde(rename = "toNy")]
1648            to_ny: f64,
1649        }
1650        let body: OkEnvelope = self
1651            .json_post(
1652                "/swipe-at-norm-coord",
1653                &Req {
1654                    from_nx: from.0,
1655                    from_ny: from.1,
1656                    to_nx: to.0,
1657                    to_ny: to.1,
1658                },
1659                None,
1660            )
1661            .await?;
1662        body.require_ok("/swipe-at-norm-coord")?;
1663        Ok(())
1664    }
1665
1666    /// `POST /find` — boolean existence quick-probe.
1667    pub async fn find(
1668        &self,
1669        selector: &Selector,
1670        include: Option<IncludeScope>,
1671    ) -> Result<bool, RunnerTransportError> {
1672        #[derive(Serialize)]
1673        struct Req<'a> {
1674            selector: &'a Selector,
1675        }
1676        #[derive(Deserialize)]
1677        struct Resp {
1678            /// Wire field name. iOS SmixRunner emits `found`.
1679            #[serde(default)]
1680            found: bool,
1681            /// Legacy alias — historical wire used `exists`.
1682            #[serde(default)]
1683            exists: bool,
1684        }
1685        // Previously read `exists || ok`, but `ok` is the transport-
1686        // level "runner responded 200" flag that is TRUE for both
1687        // found and not-found. That short-circuited the `find`
1688        // predicate to always-true, defeating when-visible logic.
1689        // SmixRunner emits `found`; historical wire had `exists`.
1690        // Accept either; do NOT fall back to `ok`.
1691        let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
1692        Ok(r.found || r.exists)
1693    }
1694
1695    /// `POST /find` with `requireOnScreen: true`. Like
1696    /// [`Self::find`] but `found` additionally requires the LIVE
1697    /// element frame to intersect the app frame. Used by the driver's
1698    /// on-screen confirmation pass: iOS 26.5 + RN Fabric SNAPSHOT
1699    /// frames drift for below-the-fold elements (stale in-viewport
1700    /// coords with visible=true), so tree-tier visibility can
1701    /// false-green a wait that the subsequent tap then honestly
1702    /// fails. The live query re-resolves current layout. Frame ∩
1703    /// viewport is checked instead of `isHittable` deliberately —
1704    /// hittability is false under floating overlays (QA bubbles),
1705    /// which are genuinely visible and assertable.
1706    pub async fn find_on_screen(
1707        &self,
1708        selector: &Selector,
1709        include: Option<IncludeScope>,
1710    ) -> Result<bool, RunnerTransportError> {
1711        #[derive(Serialize)]
1712        struct Req<'a> {
1713            selector: &'a Selector,
1714            #[serde(rename = "requireOnScreen")]
1715            require_on_screen: bool,
1716        }
1717        #[derive(Deserialize)]
1718        struct Resp {
1719            #[serde(default)]
1720            found: bool,
1721            #[serde(default)]
1722            exists: bool,
1723        }
1724        let r: Resp = self
1725            .json_post(
1726                "/find",
1727                &Req {
1728                    selector,
1729                    require_on_screen: true,
1730                },
1731                include,
1732            )
1733            .await?;
1734        Ok(r.found || r.exists)
1735    }
1736
1737    /// `POST /fill` — fill text into focused / matched input.
1738    pub async fn fill(
1739        &self,
1740        selector: &Selector,
1741        text: &str,
1742        include: Option<IncludeScope>,
1743    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1744        #[derive(Serialize)]
1745        struct Req<'a> {
1746            selector: &'a Selector,
1747            text: &'a str,
1748        }
1749        self.json_post("/fill", &Req { selector, text }, include)
1750            .await
1751    }
1752
1753    /// `POST /clear` — clear focused / matched input.
1754    pub async fn clear(
1755        &self,
1756        selector: &Selector,
1757        include: Option<IncludeScope>,
1758    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1759        #[derive(Serialize)]
1760        struct Req<'a> {
1761            selector: &'a Selector,
1762        }
1763        self.json_post("/clear", &Req { selector }, include).await
1764    }
1765
1766    /// `POST /press-key` — press named key (Return/Tab/Delete/etc.).
1767    pub async fn press_key(
1768        &self,
1769        key: KeyName,
1770    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
1771        #[derive(Serialize)]
1772        struct Req {
1773            key: KeyName,
1774        }
1775        self.json_post("/press-key", &Req { key }, None).await
1776    }
1777
1778    /// `POST /scroll {selector, direction, include?}` — scroll-until-visible.
1779    pub async fn scroll(
1780        &self,
1781        selector: &RunnerScrollSelector,
1782        direction: SwipeDirection,
1783        include: Option<IncludeScope>,
1784    ) -> Result<u32, RunnerTransportError> {
1785        #[derive(Serialize)]
1786        struct Req<'a> {
1787            selector: &'a RunnerScrollSelector,
1788            direction: SwipeDirection,
1789        }
1790        #[derive(Deserialize)]
1791        struct Resp {
1792            #[serde(default)]
1793            matched: Option<bool>,
1794            #[serde(default)]
1795            swipes: Option<u32>,
1796        }
1797        let r: Resp = self
1798            .json_post(
1799                "/scroll",
1800                &Req {
1801                    selector,
1802                    direction,
1803                },
1804                include,
1805            )
1806            .await?;
1807        let matched = r.matched.unwrap_or(false);
1808        let swipes = r.swipes.unwrap_or(0);
1809        if !matched {
1810            // Driver layer is responsible for converting matched:false
1811            // to ExpectationFailure(ELEMENT_NOT_FOUND); here we surface
1812            // via MalformedBody when shape is missing entirely, otherwise
1813            // return the swipe count for the caller to inspect.
1814            //
1815            // We return swipes; driver wraps via RunnerScrollNotMatched.
1816            return Err(RunnerTransportError::MalformedBody {
1817                endpoint: "/scroll".into(),
1818                detail: format!("not_matched after {swipes} swipes"),
1819            });
1820        }
1821        Ok(swipes)
1822    }
1823
1824    /// `POST /swipe-once {direction}` — single swipe, no probe.
1825    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
1826        #[derive(Serialize)]
1827        struct Req {
1828            direction: SwipeDirection,
1829        }
1830        let body: OkEnvelope = self
1831            .json_post("/swipe-once", &Req { direction }, None)
1832            .await?;
1833        body.require_ok("/swipe-once")?;
1834        Ok(())
1835    }
1836
1837    /// `POST /foreground {bundleId}` — bring app to foreground.
1838    pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
1839        #[derive(Serialize)]
1840        struct Req<'a> {
1841            #[serde(rename = "bundleId")]
1842            bundle_id: &'a str,
1843        }
1844        let body: OkEnvelope = self
1845            .json_post("/foreground", &Req { bundle_id }, None)
1846            .await?;
1847        body.require_ok("/foreground")?;
1848        Ok(())
1849    }
1850
1851    /// `POST /hide-keyboard`.
1852    pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
1853        let body: OkEnvelope = self
1854            .json_post("/hide-keyboard", &serde_json::json!({}), None)
1855            .await?;
1856        body.require_ok("/hide-keyboard")?;
1857        Ok(())
1858    }
1859
1860    /// `POST /back` — back gesture.
1861    pub async fn back(&self) -> Result<(), RunnerTransportError> {
1862        let body: OkEnvelope = self
1863            .json_post("/back", &serde_json::json!({}), None)
1864            .await?;
1865        body.require_ok("/back")?;
1866        Ok(())
1867    }
1868
1869    // ---- recorder ----
1870
1871    /// `POST /record/start` — begin recording.
1872    pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
1873        let body: OkEnvelope = self
1874            .json_post("/record/start", &serde_json::json!({}), None)
1875            .await?;
1876        body.require_ok("/record/start")?;
1877        Ok(())
1878    }
1879
1880    /// `GET /record/poll` — drain recorded events.
1881    pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1882        #[derive(Deserialize)]
1883        struct Envelope {
1884            events: Vec<RecordedEvent>,
1885        }
1886        let env: Envelope = self.json_get("/record/poll", None).await?;
1887        Ok(env.events)
1888    }
1889
1890    /// `POST /record/stop` — stop recording, drain final events.
1891    pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
1892        #[derive(Deserialize)]
1893        struct Envelope {
1894            events: Vec<RecordedEvent>,
1895        }
1896        let env: Envelope = self
1897            .json_post("/record/stop", &serde_json::json!({}), None)
1898            .await?;
1899        Ok(env.events)
1900    }
1901
1902    /// `POST /record/stop` reading events as raw IRAction JSON, for runners
1903    /// that emit IRAction directly (Android). The iOS `stop_record` above reads
1904    /// `RecordedEvent` (raw AX codes); reading IRAction through that shape would
1905    /// swallow `kind` / `selector` into its `extra`, so this keeps them raw
1906    /// `Value`s for the record -> generate glue to hand to the generator.
1907    pub async fn stop_record_actions(
1908        &self,
1909    ) -> Result<Vec<serde_json::Value>, RunnerTransportError> {
1910        let env: RecordStopEnvelope = self
1911            .json_post("/record/stop", &serde_json::json!({}), None)
1912            .await?;
1913        Ok(env.events)
1914    }
1915}
1916
1917/// `{ "events": [ <IRAction JSON>, ... ] }` — the Android `/record/stop` body,
1918/// read as raw [`serde_json::Value`]s so `kind` / `selector` survive intact
1919/// for the record -> generate glue. Hoisted to module scope so the test below
1920/// locks the exact shape [`HttpRunnerClient::stop_record_actions`] depends on.
1921#[derive(Deserialize)]
1922struct RecordStopEnvelope {
1923    #[serde(default)]
1924    events: Vec<serde_json::Value>,
1925}
1926
1927#[cfg(test)]
1928mod record_actions_tests {
1929    use super::*;
1930
1931    // Locks that the envelope preserves IRAction structure — the reason
1932    // stop_record_actions exists apart from stop_record, whose RecordedEvent
1933    // shape swallows kind/selector into `extra`. Deserializes into the SAME
1934    // type the method uses, so field-name/serde drift fails here.
1935    #[test]
1936    fn stop_record_actions_envelope_preserves_iraction_structure() {
1937        let body = r#"{"events":[
1938            {"kind":"tap","selector":{"id":"field"},"timestampMs":1},
1939            {"kind":"fill","selector":{"id":"field"},"text":"smix","timestampMs":2},
1940            {"kind":"clear","selector":{"id":"field"},"timestampMs":3}
1941        ]}"#;
1942        let env: RecordStopEnvelope = serde_json::from_str(body).unwrap();
1943        let kinds: Vec<&str> = env
1944            .events
1945            .iter()
1946            .map(|e| e["kind"].as_str().unwrap())
1947            .collect();
1948        assert_eq!(kinds, ["tap", "fill", "clear"]);
1949        assert_eq!(env.events[0]["selector"]["id"], "field");
1950        assert_eq!(env.events[1]["text"], "smix");
1951    }
1952
1953    // A runner that emits nothing (or omits the field) drains to empty, not error
1954    // — `#[serde(default)]`. An empty drain is the generator's EmptySession to
1955    // reject, not a transport failure to raise here.
1956    #[test]
1957    fn stop_record_actions_envelope_defaults_empty() {
1958        let env: RecordStopEnvelope = serde_json::from_str("{}").unwrap();
1959        assert!(env.events.is_empty());
1960    }
1961}