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