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