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