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