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