Skip to main content

smix_runner_client/
lib.rs

1//! smix-runner-client — HTTP IPC client to `swift-bridge/SmixRunnerCore`
2//! (outer crate). v3.1 c8, **first outer crate** allowing reqwest +
3//! tokio + thiserror + tracing deps per user 2026-05-25 brief.
4//!
5//! Ported from now-retired TS source: `src/sim/runner-client.ts` (1129 lines). Wire-level
6//! 1:1 compatibility with the Swift side — any route shape / query param
7//! / response body shape change must be done in lockstep with
8//! `swift-bridge/Sources/SmixRunnerCore`.
9//!
10//! # Routes (18 endpoints, port progress c8 = MVP set)
11//!
12//! - `GET /health` — connectivity probe (memoized via [`HttpRunnerClient::ensure_reachable`])
13//! - `GET /tree?include=` — full a11y tree dump (returns [`A11yNode`])
14//! - `GET /system-popups?include=` — list of [`SystemPopup`]
15//! - `POST /tap {selector, mode, include?}` — selector → element tap
16//! - `POST /tap-at-norm-coord {nx, ny}` — Apple native event chain coord tap
17//! - `POST /find {selector, include?}` — boolean existence check (v1.4 quick-probe)
18//! - `POST /fill {selector, text, include?}` — fill text into input
19//! - `POST /clear {selector, include?}` — clear input
20//! - `POST /press-key {key}` — press named key (Return/Tab/etc.)
21//! - `POST /scroll {selector, direction, include?}` — scroll-until-visible
22//! - `POST /swipe-once {direction}` — single swipe gesture (no probe loop)
23//! - `POST /foreground {bundleId}` — bring app to foreground
24//! - `POST /hide-keyboard` — swipe-down to dismiss keyboard
25//! - `POST /back` — back gesture
26//! - `POST /record/start` — begin recording AX notifications
27//! - `GET /record/poll` — drain recorded events
28//! - `POST /record/stop` — stop recording, drain final events
29//!
30//! Per CLAUDE.md §9 #4 — never expose raw URL / sleep / xpath surfaces.
31//! All operations go through the typed methods below.
32
33#![doc(html_root_url = "https://docs.smix.dev/smix-runner-client")]
34
35use serde::{Deserialize, Serialize};
36use smix_input::{KeyName, SwipeDirection};
37use smix_screen::{A11yNode, derive_roles_recursive};
38use smix_selector::Selector;
39use std::sync::Arc;
40use std::sync::atomic::{AtomicBool, Ordering};
41use std::time::Duration;
42use thiserror::Error;
43
44// -------------------- Error types ----------------------------------------
45
46/// Transport-level failure (network, non-2xx, malformed body). Mirrors
47/// TS `RunnerTransportError` 1:1.
48#[derive(Debug, Error)]
49pub enum RunnerTransportError {
50    #[error("runner {endpoint} fetch failed: {source}")]
51    FetchFailed {
52        endpoint: String,
53        #[source]
54        source: reqwest::Error,
55    },
56    #[error("runner {endpoint} returned status {status}: {body}")]
57    NonSuccessStatus {
58        endpoint: String,
59        status: u16,
60        body: String,
61    },
62    #[error("runner {endpoint} returned non-JSON body: {source}")]
63    NonJsonBody {
64        endpoint: String,
65        #[source]
66        source: reqwest::Error,
67    },
68    #[error("runner {endpoint} returned malformed body: {detail}")]
69    MalformedBody { endpoint: String, detail: String },
70    #[error("runner {endpoint} unreachable: {message}")]
71    Unreachable { endpoint: String, message: String },
72}
73
74/// `POST /tap` returned `404 / not_found` — element matched no node.
75/// Mirrors TS `TapNotFoundError`.
76#[derive(Debug, Error)]
77#[error("tap not_found for selector {selector_json}: {body}")]
78pub struct TapNotFoundError {
79    pub selector_json: String,
80    pub body: String,
81}
82
83/// `POST /scroll` returned `200 / matched:false / swipes:N` — scroll
84/// exhausted N swipes without surfacing the target. Mirrors TS
85/// `RunnerScrollNotMatched`.
86#[derive(Debug, Error)]
87#[error("scroll not_matched after {swipes} swipes for selector {selector_json}")]
88pub struct RunnerScrollNotMatched {
89    pub selector_json: String,
90    pub swipes: u32,
91}
92
93/// `POST /swipe-once` returned `200 / ok:false / vanished_during_swipe`.
94/// Mirrors TS `RunnerSwipeOnceFailure`.
95#[derive(Debug, Error)]
96#[error("swipeOnce failed: {detail}")]
97pub struct RunnerSwipeOnceFailure {
98    pub detail: String,
99}
100
101/// v5.19 c1 — normalized OCR bounding box returned by
102/// [`HttpRunnerClient::find_text_by_ocr`]. Coordinates are normalized to
103/// `[0, 1]` in UIKit coord space (top-left origin, y-down). Apple Vision's
104/// native bbox is bottom-left origin + y-up — the swift handler converts
105/// before returning so all consumers see UIKit coords.
106#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
107pub struct OcrFrame {
108    /// Top-left x in `[0, 1]`.
109    pub nx: f64,
110    /// Top-left y in `[0, 1]`.
111    pub ny: f64,
112    /// Width in `[0, 1]`.
113    pub w: f64,
114    /// Height in `[0, 1]`.
115    pub h: f64,
116}
117
118impl OcrFrame {
119    /// Center x in `[0, 1]`.
120    #[must_use]
121    pub fn mid_x(&self) -> f64 {
122        self.nx + self.w * 0.5
123    }
124    /// Center y in `[0, 1]`.
125    #[must_use]
126    pub fn mid_y(&self) -> f64 {
127        self.ny + self.h * 0.5
128    }
129}
130
131// -------------------- Wire types ----------------------------------------
132//
133// v3.2 c6 — wire types now live in the smix-runner-wire stone. The HTTP
134// client (this crate) re-exports them so existing consumers don't break.
135// Anyone needing the wire shapes without the reqwest+tokio HTTP impl
136// should depend on smix-runner-wire directly.
137
138pub use smix_runner_wire::{
139    FindRequest, FindResponse, IncludeScope, KeyboardStages, RecordEventsResponse, RecordedEvent,
140    RunnerIncludeOpts, RunnerKeyboardResult, RunnerScrollSelector, ScrollResponse, SystemPopup,
141    SystemPopupActionRequest, SystemPopupActionResponse, SystemPopupButton, SystemPopupsResponse,
142    TapAtNormCoordRequest, TapMode, TapRequest, TapResult, TapStages,
143};
144
145// -------------------- Transport retry constants -------------------------
146
147/// Total transport attempts (1 initial + 2 retries) for transient reqwest
148/// connection errors. See `send_with_retry` doc.
149const TRANSPORT_MAX_ATTEMPTS: u32 = 3;
150/// Backoff between transport retry attempts.
151const TRANSPORT_BACKOFF_MS: u64 = 100;
152
153// -------------------- Client --------------------------------------------
154
155/// HTTP client to the swift-side SmixRunnerCore. Async (tokio-based).
156/// Constructed once per Cell; `ensure_reachable` memoizes a successful
157/// `/health` probe so subsequent calls don't re-probe.
158#[derive(Debug)]
159pub struct HttpRunnerClient {
160    base: String,
161    client: reqwest::Client,
162    reachable: Arc<AtomicBool>,
163}
164
165impl HttpRunnerClient {
166    /// Construct a client targeting `http://127.0.0.1:{port}`.
167    pub fn new(port: u16) -> Self {
168        Self::with_base(format!("http://127.0.0.1:{port}"))
169    }
170
171    /// Construct with an explicit base URL (test / non-localhost cases).
172    pub fn with_base<S: Into<String>>(base: S) -> Self {
173        let client = reqwest::Client::builder()
174            .timeout(Duration::from_secs(15))
175            .build()
176            .expect("reqwest::Client::builder default never fails");
177        HttpRunnerClient {
178            base: base.into(),
179            client,
180            reachable: Arc::new(AtomicBool::new(false)),
181        }
182    }
183
184    /// Base URL accessor.
185    pub fn base(&self) -> &str {
186        &self.base
187    }
188
189    // ---- low-level helpers ----
190
191    fn url(&self, endpoint: &str, include: Option<IncludeScope>) -> String {
192        match include {
193            Some(s) => format!("{}{}?include={}", self.base, endpoint, s.query_value()),
194            None => format!("{}{}", self.base, endpoint),
195        }
196    }
197
198    /// v4.1 c5 — wire-level transport retry for transient reqwest connection
199    /// errors (`is_connect()` / `is_request()` — pre-server-receipt failures
200    /// raised before any bytes leave the local socket, idempotent-safe for
201    /// every route). Server-side `5xx` / `NonJsonBody` are NOT retried here:
202    /// those are semantic responses, retried (or not) by callers like
203    /// `driver.find`'s 500 retry loop or `driver.wait_for`'s selector poll.
204    ///
205    /// Budget: 3 attempts total (1 initial + 2 retries) × `TRANSPORT_BACKOFF_MS`
206    /// between attempts. Empirically catches the v4.1 c4 baseline regression
207    /// signature (sim/runner socket hiccup mid-flow) without inflating happy-
208    /// path latency: retries only fire on actual failure.
209    async fn send_with_retry<F>(
210        &self,
211        endpoint: &str,
212        builder_fn: F,
213    ) -> Result<reqwest::Response, RunnerTransportError>
214    where
215        F: Fn(&reqwest::Client) -> reqwest::RequestBuilder,
216    {
217        let mut attempts_left = TRANSPORT_MAX_ATTEMPTS;
218        loop {
219            attempts_left -= 1;
220            match builder_fn(&self.client).send().await {
221                Ok(res) => return Ok(res),
222                Err(e) => {
223                    let retryable = e.is_connect() || e.is_request();
224                    if !retryable || attempts_left == 0 {
225                        return Err(RunnerTransportError::FetchFailed {
226                            endpoint: endpoint.to_string(),
227                            source: e,
228                        });
229                    }
230                    tokio::time::sleep(Duration::from_millis(TRANSPORT_BACKOFF_MS)).await;
231                }
232            }
233        }
234    }
235
236    async fn json_get<T: for<'de> Deserialize<'de>>(
237        &self,
238        endpoint: &str,
239        include: Option<IncludeScope>,
240    ) -> Result<T, RunnerTransportError> {
241        let url = self.url(endpoint, include);
242        let res = self.send_with_retry(endpoint, |c| c.get(&url)).await?;
243        let status = res.status();
244        if !status.is_success() {
245            let body = res.text().await.unwrap_or_default();
246            return Err(RunnerTransportError::NonSuccessStatus {
247                endpoint: endpoint.to_string(),
248                status: status.as_u16(),
249                body: body.chars().take(200).collect(),
250            });
251        }
252        res.json::<T>()
253            .await
254            .map_err(|source| RunnerTransportError::NonJsonBody {
255                endpoint: endpoint.to_string(),
256                source,
257            })
258    }
259
260    async fn json_post<B: Serialize, T: for<'de> Deserialize<'de>>(
261        &self,
262        endpoint: &str,
263        body: &B,
264        include: Option<IncludeScope>,
265    ) -> Result<T, RunnerTransportError> {
266        let url = self.url(endpoint, include);
267        let res = self
268            .send_with_retry(endpoint, |c| c.post(&url).json(body))
269            .await?;
270        let status = res.status();
271        if !status.is_success() {
272            let body = res.text().await.unwrap_or_default();
273            return Err(RunnerTransportError::NonSuccessStatus {
274                endpoint: endpoint.to_string(),
275                status: status.as_u16(),
276                body: body.chars().take(200).collect(),
277            });
278        }
279        res.json::<T>()
280            .await
281            .map_err(|source| RunnerTransportError::NonJsonBody {
282                endpoint: endpoint.to_string(),
283                source,
284            })
285    }
286
287    // ---- public methods (mirrors TS RunnerClient surface) ----
288
289    /// `GET /health` — bare alive probe (no memoization). Returns true on 2xx.
290    pub async fn health(&self) -> bool {
291        let url = self.url("/health", None);
292        match self.client.get(&url).send().await {
293            Ok(res) => res.status().is_success(),
294            Err(_) => false,
295        }
296    }
297
298    /// `GET /health` memoized — first call probes; subsequent are no-ops
299    /// after one success. Failure raises [`RunnerTransportError::Unreachable`].
300    /// Mirrors TS `ensureReachable`.
301    pub async fn ensure_reachable(&self) -> Result<(), RunnerTransportError> {
302        if self.reachable.load(Ordering::Acquire) {
303            return Ok(());
304        }
305        if self.health().await {
306            self.reachable.store(true, Ordering::Release);
307            return Ok(());
308        }
309        Err(RunnerTransportError::Unreachable {
310            endpoint: "/health".into(),
311            message: format!("SmixRunner not reachable at {}", self.base),
312        })
313    }
314
315    /// `GET /tree?include=` — full a11y tree dump.
316    ///
317    /// v3.5 c3: post-deser pass [`derive_roles_recursive`] fills
318    /// `A11yNode.role` from `raw_type` because the Swift `/tree` route
319    /// only emits `rawType` on the wire. Without this, `Selector::Role`
320    /// would never match real-sim payloads.
321    pub async fn get_tree(
322        &self,
323        include: Option<IncludeScope>,
324    ) -> Result<A11yNode, RunnerTransportError> {
325        let mut tree: A11yNode = self.json_get("/tree", include).await?;
326        derive_roles_recursive(&mut tree);
327        Ok(tree)
328    }
329
330    /// `GET /system-popups?include=` — list system popups.
331    pub async fn system_popups(
332        &self,
333        include: Option<IncludeScope>,
334    ) -> Result<Vec<SystemPopup>, RunnerTransportError> {
335        #[derive(Deserialize)]
336        struct Envelope {
337            popups: Vec<SystemPopup>,
338        }
339        let env: Envelope = self.json_get("/system-popups", include).await?;
340        Ok(env.popups)
341    }
342
343    /// `POST /system-popup-action` (v4.2 c2 — G9 act side). Tap a button
344    /// on a previously enumerated popup. `popup_id` and `button_id` are
345    /// the `Popup.id` / `PopupButton.id` strings returned by
346    /// [`Self::system_popups`]; the runner walks the same scan order so
347    /// the round-trip does not need a host-side id map.
348    ///
349    /// Returns `Ok(true)` when the runner matched both ids and dispatched
350    /// the tap, `Ok(false)` when the runner returned a 404 not_found
351    /// (popup id, button id, or synthesize dispatch missed). Transport
352    /// errors and non-2xx-non-404 statuses surface as
353    /// [`RunnerTransportError`].
354    pub async fn system_popup_action(
355        &self,
356        popup_id: &str,
357        button_id: &str,
358    ) -> Result<bool, RunnerTransportError> {
359        let url = self.url("/system-popup-action", None);
360        let body = SystemPopupActionRequest {
361            popup_id: popup_id.to_string(),
362            button_id: button_id.to_string(),
363        };
364        let res = self
365            .send_with_retry("/system-popup-action", |c| c.post(&url).json(&body))
366            .await?;
367        let status = res.status();
368        if status.as_u16() == 404 {
369            return Ok(false);
370        }
371        if !status.is_success() {
372            let body = res.text().await.unwrap_or_default();
373            return Err(RunnerTransportError::NonSuccessStatus {
374                endpoint: "/system-popup-action".to_string(),
375                status: status.as_u16(),
376                body: body.chars().take(200).collect(),
377            });
378        }
379        let resp: SystemPopupActionResponse =
380            res.json()
381                .await
382                .map_err(|source| RunnerTransportError::NonJsonBody {
383                    endpoint: "/system-popup-action".to_string(),
384                    source,
385                })?;
386        Ok(resp.ok)
387    }
388
389    /// `POST /tap` — selector → element tap. Errors:
390    /// - 404 → [`TapNotFoundError`] returned via `Err(.. Other ..)`. (TS
391    ///   throws subtype; here we surface via the dedicated variant of a
392    ///   future TapError type — for c8 MVP we keep the simple
393    ///   `RunnerTransportError + body inspection` pattern. Driver layer
394    ///   wraps with ExpectationFailure.)
395    pub async fn tap(
396        &self,
397        selector: &Selector,
398        mode: TapMode,
399        include: Option<IncludeScope>,
400    ) -> Result<TapResult, RunnerTransportError> {
401        #[derive(Serialize)]
402        struct Req<'a> {
403            selector: &'a Selector,
404            mode: TapMode,
405        }
406        self.json_post("/tap", &Req { selector, mode }, include)
407            .await
408    }
409
410    /// `POST /tap-at-norm-coord` (v1.6 c5) — Apple native UI event coord tap.
411    pub async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), RunnerTransportError> {
412        #[derive(Serialize)]
413        struct Req {
414            nx: f64,
415            ny: f64,
416        }
417        let _: serde_json::Value = self
418            .json_post("/tap-at-norm-coord", &Req { nx, ny }, None)
419            .await?;
420        Ok(())
421    }
422
423    /// `POST /double-tap-at-norm-coord` (v6.0 c3c-v) — double-tap at
424    /// viewport-normalized coord. Android-specific endpoint backing
425    /// `AndroidDriver::double_tap` after host-resolve. iOS path uses
426    /// selector-based `/double-tap`; this exists for Android where the
427    /// runner does its own host-resolve via tree dump.
428    pub async fn double_tap_at_norm_coord(
429        &self,
430        nx: f64,
431        ny: f64,
432    ) -> Result<(), RunnerTransportError> {
433        #[derive(Serialize)]
434        struct Req {
435            nx: f64,
436            ny: f64,
437        }
438        let _: serde_json::Value = self
439            .json_post("/double-tap-at-norm-coord", &Req { nx, ny }, None)
440            .await?;
441        Ok(())
442    }
443
444    /// `POST /long-press-at-norm-coord` (v6.0 c3c-v) — long-press at coord
445    /// with explicit duration. Android-specific.
446    pub async fn long_press_at_norm_coord(
447        &self,
448        nx: f64,
449        ny: f64,
450        duration_ms: u64,
451    ) -> Result<(), RunnerTransportError> {
452        #[derive(Serialize)]
453        struct Req {
454            nx: f64,
455            ny: f64,
456            #[serde(rename = "durationMs")]
457            duration_ms: u64,
458        }
459        let _: serde_json::Value = self
460            .json_post(
461                "/long-press-at-norm-coord",
462                &Req {
463                    nx,
464                    ny,
465                    duration_ms,
466                },
467                None,
468            )
469            .await?;
470        Ok(())
471    }
472
473    /// `POST /input-text` (v6.0 c3c-v) — type text into currently-focused
474    /// input. Caller must tap to focus the field first (AndroidDriver
475    /// orchestrates). Android-specific.
476    pub async fn input_text(&self, text: &str) -> Result<(), RunnerTransportError> {
477        #[derive(Serialize)]
478        struct Req<'a> {
479            text: &'a str,
480        }
481        let _: serde_json::Value = self.json_post("/input-text", &Req { text }, None).await?;
482        Ok(())
483    }
484
485    /// `POST /tap-by-id` (v5.3 c4) — `XCUIElement.tap()` via the XCTest
486    /// gesture-recognizer chain. Drives SwiftUI `.sheet` / `.alert` /
487    /// `.confirmationDialog` / `.fullScreenCover` dismiss bindings that the
488    /// default host-HID-at-coord path can't trigger. Returns
489    /// `Ok(true)` when the element was found and tapped, `Ok(false)` when
490    /// the runner reported `ok=false` (element not found / NSException
491    /// surfaced through `smixGuarded`). Parallel to
492    /// `tap_at_norm_coord`; the existing `/tap` envelope stays untouched.
493    pub async fn tap_by_id(&self, id: &str) -> Result<bool, RunnerTransportError> {
494        #[derive(Serialize)]
495        struct Req<'a> {
496            id: &'a str,
497        }
498        #[derive(Deserialize)]
499        struct Resp {
500            ok: bool,
501        }
502        let resp: Resp = self.json_post("/tap-by-id", &Req { id }, None).await?;
503        Ok(resp.ok)
504    }
505
506    /// `POST /find-text-by-ocr` (v5.19 c1) — Apple Vision OCR over the
507    /// current XCUIScreen screenshot. Returns the matching text
508    /// observation's bounding box normalized to `[0, 1]` in UIKit coord
509    /// space (top-left origin, y-down). `Ok(None)` when no observation
510    /// matches the keyword. L5 sense layer for the a11y-less + i18n
511    /// initiative; covers ~40-50% of "lib without testID but with
512    /// visible text" scenarios per master plan §1.
513    ///
514    /// `locales` are BCP-47 language subtags (e.g. `["en"]` / `["ja"]`);
515    /// empty defaults to `["en"]` on the swift side. `recognition_level`
516    /// is `"accurate"` (default, slower + higher accuracy) or `"fast"`.
517    pub async fn find_text_by_ocr(
518        &self,
519        text: &str,
520        locales: &[String],
521        recognition_level: &str,
522    ) -> Result<Option<OcrFrame>, RunnerTransportError> {
523        #[derive(Serialize)]
524        struct Req<'a> {
525            text: &'a str,
526            locales: &'a [String],
527            recognition_level: &'a str,
528        }
529        #[derive(Deserialize)]
530        struct Resp {
531            found: bool,
532            frame: Option<[f64; 4]>,
533        }
534        let resp: Resp = self
535            .json_post(
536                "/find-text-by-ocr",
537                &Req {
538                    text,
539                    locales,
540                    recognition_level,
541                },
542                None,
543            )
544            .await?;
545        if !resp.found {
546            return Ok(None);
547        }
548        match resp.frame {
549            Some([nx, ny, w, h]) => Ok(Some(OcrFrame { nx, ny, w, h })),
550            None => Ok(None),
551        }
552    }
553
554    /// v5.21 c1b — Eval JS against fixture-side WKWebView bridge (Option
555    /// A per a11y-i18n master plan §1 L5+). Does NOT use the XCUITest
556    /// runner — POSTs directly to the app-side debug bridge on
557    /// `127.0.0.1:28080/eval` (iOS sim shares host loopback). Returns
558    /// the JS eval result as JSON Value or runtime error from the bridge.
559    ///
560    /// **Scope**: works for any app that exposes the
561    /// `SmixWebViewBridge` (selftest-fixture has it by default; user apps
562    /// must opt in). Bridge port is fixed at 28080 today.
563    pub async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, RunnerTransportError> {
564        #[derive(Serialize)]
565        struct Req<'a> {
566            js: &'a str,
567        }
568        #[derive(Deserialize)]
569        struct Resp {
570            result: serde_json::Value,
571            error: String,
572        }
573        let url = "http://127.0.0.1:28080/eval";
574        let resp = reqwest::Client::new()
575            .post(url)
576            .json(&Req { js })
577            .send()
578            .await
579            .map_err(|e| RunnerTransportError::Unreachable {
580                endpoint: "webview-bridge".into(),
581                message: format!("webview-bridge POST: {e}"),
582            })?;
583        let status = resp.status();
584        let body: Resp = resp
585            .json()
586            .await
587            .map_err(|e| RunnerTransportError::Unreachable {
588                endpoint: "webview-bridge".into(),
589                message: format!("webview-bridge JSON decode (status {status}): {e}"),
590            })?;
591        if !body.error.is_empty() {
592            return Err(RunnerTransportError::Unreachable {
593                endpoint: "webview-bridge".into(),
594                message: format!("webview-bridge JS error: {}", body.error),
595            });
596        }
597        Ok(body.result)
598    }
599
600    /// `POST /double-tap` (v5.2 c3) — XCUIElement.doubleTap() public API.
601    /// Mirrors `/tap` envelope but issues two-tap gesture on the resolved
602    /// element. Maestro `doubleTapOn` 同源.
603    pub async fn double_tap(
604        &self,
605        selector: &Selector,
606        include: Option<IncludeScope>,
607    ) -> Result<TapResult, RunnerTransportError> {
608        #[derive(Serialize)]
609        struct Req<'a> {
610            selector: &'a Selector,
611        }
612        self.json_post("/double-tap", &Req { selector }, include)
613            .await
614    }
615
616    /// `POST /long-press` (v5.2 c3) — XCUIElement.press(forDuration:) public
617    /// API. `duration_ms` 来自 maestro yaml `duration:`, default 500.
618    /// Maestro `longPressOn` 同源.
619    pub async fn long_press(
620        &self,
621        selector: &Selector,
622        duration_ms: u64,
623        include: Option<IncludeScope>,
624    ) -> Result<TapResult, RunnerTransportError> {
625        #[derive(Serialize)]
626        struct Req<'a> {
627            selector: &'a Selector,
628            #[serde(rename = "durationMs")]
629            duration_ms: u64,
630        }
631        self.json_post(
632            "/long-press",
633            &Req {
634                selector,
635                duration_ms,
636            },
637            include,
638        )
639        .await
640    }
641
642    /// `POST /set-orientation` (v5.2 c5) — rotate sim via swift
643    /// `XCUIDevice.shared.orientation` setter. `orientation` literal:
644    /// "portrait" | "portraitUpsideDown" | "landscapeLeft" | "landscapeRight".
645    pub async fn set_orientation(&self, orientation: &str) -> Result<(), RunnerTransportError> {
646        #[derive(Serialize)]
647        struct Req<'a> {
648            orientation: &'a str,
649        }
650        let _: serde_json::Value = self
651            .json_post("/set-orientation", &Req { orientation }, None)
652            .await?;
653        Ok(())
654    }
655
656    /// `POST /swipe-at-norm-coord` (v5.2 c1) — Apple native UI event from-to
657    /// swipe. Sibling of [`Self::tap_at_norm_coord`] under §9 #3 escape hatch.
658    pub async fn swipe_at_norm_coord(
659        &self,
660        from: (f64, f64),
661        to: (f64, f64),
662    ) -> Result<(), RunnerTransportError> {
663        #[derive(Serialize)]
664        struct Req {
665            #[serde(rename = "fromNx")]
666            from_nx: f64,
667            #[serde(rename = "fromNy")]
668            from_ny: f64,
669            #[serde(rename = "toNx")]
670            to_nx: f64,
671            #[serde(rename = "toNy")]
672            to_ny: f64,
673        }
674        let _: serde_json::Value = self
675            .json_post(
676                "/swipe-at-norm-coord",
677                &Req {
678                    from_nx: from.0,
679                    from_ny: from.1,
680                    to_nx: to.0,
681                    to_ny: to.1,
682                },
683                None,
684            )
685            .await?;
686        Ok(())
687    }
688
689    /// `POST /find` (v1.2 C4) — boolean existence quick-probe.
690    pub async fn find(
691        &self,
692        selector: &Selector,
693        include: Option<IncludeScope>,
694    ) -> Result<bool, RunnerTransportError> {
695        #[derive(Serialize)]
696        struct Req<'a> {
697            selector: &'a Selector,
698        }
699        #[derive(Deserialize)]
700        struct Resp {
701            /// v0.2.0 wire field name. iOS SmixRunner emits `found`.
702            #[serde(default)]
703            found: bool,
704            /// Legacy alias — historical wire used `exists`.
705            #[serde(default)]
706            exists: bool,
707        }
708        // gol-611 §4 (v0.2.0) — previously read `exists || ok`, but
709        // `ok` is the transport-level "runner responded 200" flag
710        // that is TRUE for both found and not-found. That short-
711        // circuited the `find` predicate to always-true, defeating
712        // when-visible logic (insight Path B PoC repro). SmixRunner
713        // emits `found`; historical wire had `exists`. Accept
714        // either; do NOT fall back to `ok`.
715        let r: Resp = self.json_post("/find", &Req { selector }, include).await?;
716        Ok(r.found || r.exists)
717    }
718
719    /// `POST /fill` — fill text into focused / matched input.
720    pub async fn fill(
721        &self,
722        selector: &Selector,
723        text: &str,
724        include: Option<IncludeScope>,
725    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
726        #[derive(Serialize)]
727        struct Req<'a> {
728            selector: &'a Selector,
729            text: &'a str,
730        }
731        self.json_post("/fill", &Req { selector, text }, include)
732            .await
733    }
734
735    /// `POST /clear` — clear focused / matched input.
736    pub async fn clear(
737        &self,
738        selector: &Selector,
739        include: Option<IncludeScope>,
740    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
741        #[derive(Serialize)]
742        struct Req<'a> {
743            selector: &'a Selector,
744        }
745        self.json_post("/clear", &Req { selector }, include).await
746    }
747
748    /// `POST /press-key` — press named key (Return/Tab/Delete/etc.).
749    pub async fn press_key(
750        &self,
751        key: KeyName,
752    ) -> Result<RunnerKeyboardResult, RunnerTransportError> {
753        #[derive(Serialize)]
754        struct Req {
755            key: KeyName,
756        }
757        self.json_post("/press-key", &Req { key }, None).await
758    }
759
760    /// `POST /scroll {selector, direction, include?}` — scroll-until-visible.
761    pub async fn scroll(
762        &self,
763        selector: &RunnerScrollSelector,
764        direction: SwipeDirection,
765        include: Option<IncludeScope>,
766    ) -> Result<u32, RunnerTransportError> {
767        #[derive(Serialize)]
768        struct Req<'a> {
769            selector: &'a RunnerScrollSelector,
770            direction: SwipeDirection,
771        }
772        #[derive(Deserialize)]
773        struct Resp {
774            #[serde(default)]
775            matched: Option<bool>,
776            #[serde(default)]
777            swipes: Option<u32>,
778        }
779        let r: Resp = self
780            .json_post(
781                "/scroll",
782                &Req {
783                    selector,
784                    direction,
785                },
786                include,
787            )
788            .await?;
789        let matched = r.matched.unwrap_or(false);
790        let swipes = r.swipes.unwrap_or(0);
791        if !matched {
792            // Driver layer is responsible for converting matched:false
793            // to ExpectationFailure(ELEMENT_NOT_FOUND); here we surface
794            // via MalformedBody when shape is missing entirely, otherwise
795            // return the swipe count for the caller to inspect.
796            //
797            // For c8 MVP we return swipes; driver wraps via RunnerScrollNotMatched.
798            return Err(RunnerTransportError::MalformedBody {
799                endpoint: "/scroll".into(),
800                detail: format!("not_matched after {swipes} swipes"),
801            });
802        }
803        Ok(swipes)
804    }
805
806    /// `POST /swipe-once {direction}` (v1.5 c5i-d) — single swipe, no probe.
807    pub async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), RunnerTransportError> {
808        #[derive(Serialize)]
809        struct Req {
810            direction: SwipeDirection,
811        }
812        let _: serde_json::Value = self
813            .json_post("/swipe-once", &Req { direction }, None)
814            .await?;
815        Ok(())
816    }
817
818    /// `POST /foreground {bundleId}` — bring app to foreground.
819    pub async fn foreground(&self, bundle_id: &str) -> Result<(), RunnerTransportError> {
820        #[derive(Serialize)]
821        struct Req<'a> {
822            #[serde(rename = "bundleId")]
823            bundle_id: &'a str,
824        }
825        let _: serde_json::Value = self
826            .json_post("/foreground", &Req { bundle_id }, None)
827            .await?;
828        Ok(())
829    }
830
831    /// `POST /hide-keyboard` (v1.5 c5g'').
832    pub async fn hide_keyboard(&self) -> Result<(), RunnerTransportError> {
833        let _: serde_json::Value = self
834            .json_post("/hide-keyboard", &serde_json::json!({}), None)
835            .await?;
836        Ok(())
837    }
838
839    /// `POST /back` — back gesture.
840    pub async fn back(&self) -> Result<(), RunnerTransportError> {
841        let _: serde_json::Value = self
842            .json_post("/back", &serde_json::json!({}), None)
843            .await?;
844        Ok(())
845    }
846
847    // ---- recorder (v2.0) ----
848
849    /// `POST /record/start` — begin recording.
850    pub async fn start_record(&self) -> Result<(), RunnerTransportError> {
851        let _: serde_json::Value = self
852            .json_post("/record/start", &serde_json::json!({}), None)
853            .await?;
854        Ok(())
855    }
856
857    /// `GET /record/poll` — drain recorded events.
858    pub async fn poll_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
859        #[derive(Deserialize)]
860        struct Envelope {
861            events: Vec<RecordedEvent>,
862        }
863        let env: Envelope = self.json_get("/record/poll", None).await?;
864        Ok(env.events)
865    }
866
867    /// `POST /record/stop` — stop recording, drain final events.
868    pub async fn stop_record(&self) -> Result<Vec<RecordedEvent>, RunnerTransportError> {
869        #[derive(Deserialize)]
870        struct Envelope {
871            events: Vec<RecordedEvent>,
872        }
873        let env: Envelope = self
874            .json_post("/record/stop", &serde_json::json!({}), None)
875            .await?;
876        Ok(env.events)
877    }
878}