Skip to main content

smix_runner_wire/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-runner-wire — pure wire types for the SmixRunnerCore HTTP IPC.
6//!
7//! Stone, zero project coupling beyond the smix-screen / smix-selector
8//! upstream types it embeds (those are themselves stones).
9//!
10//! Pairs with [`smix-runner-client`](https://docs.rs/smix-runner-client)
11//! which provides the reqwest+tokio HTTP client; this crate is just the
12//! types so a consumer can:
13//!
14//! - Drive their own HTTP client (sync or async, custom transport)
15//! - Hand-roll a server side serving the same wire contract
16//! - Pin the wire-shape contract independently of the client implementation
17//!
18//! # Route surface
19//!
20//! 18 wire endpoints (see the `smix-runner-client` crate for the
21//! corresponding method names):
22//!
23//! - `GET /health` — bare 200/non-200
24//! - `GET /tree?include=…` → [`smix_screen::A11yNode`]
25//! - `GET /system-popups?include=…` → `Vec<`[`SystemPopup`]`>`
26//! - `POST /system-popup-action` `{popupId, buttonId}` → [`SystemPopupActionResponse`]
27//! - `POST /tap` → [`TapResult`]
28//! - `POST /tap-at-norm-coord` `{nx, ny}` → 200/`{ok}`
29//! - `POST /find` `{selector}` → `{exists}` or `{ok}`
30//! - `POST /fill` `{selector, text}` → [`RunnerKeyboardResult`]
31//! - `POST /clear` `{selector}` → [`RunnerKeyboardResult`]
32//! - `POST /press-key` `{key}` → [`RunnerKeyboardResult`]
33//! - `POST /scroll` `{selector, direction}` → `{matched, swipes}`
34//! - `POST /swipe-once` `{direction}` → `{ok}`
35//! - `POST /foreground` `{bundleId}` → `{ok}`
36//! - `POST /hide-keyboard` → `{ok}`
37//! - `POST /back` → `{ok}`
38//! - `POST /record/start` → `{ok}`
39//! - `GET /record/poll` → `{events: [`[`RecordedEvent`]`]}`
40//! - `POST /record/stop` → `{events: [`[`RecordedEvent`]`]}`
41
42#![doc(html_root_url = "https://docs.smix.dev/smix-runner-wire")]
43
44use serde::{Deserialize, Serialize};
45use smix_selector::Selector;
46use thiserror::Error;
47
48// -------------------- Errors --------------------------------------------
49
50/// Transport-level failure variants exposed by the HTTP client. The
51/// concrete `reqwest::Error` source lives in `smix-runner-client`; the
52/// wire stone exposes only the discriminator + endpoint context so
53/// non-HTTP transports can reuse the variants.
54#[derive(Debug, Error)]
55pub enum RunnerTransportErrorKind {
56    /// Network / transport-layer fetch error (timeout, DNS, TLS, etc.).
57    #[error("runner {endpoint} fetch failed")]
58    FetchFailed {
59        /// Endpoint path that failed (e.g. `"/tap"`).
60        endpoint: String,
61    },
62    /// Runner returned non-2xx HTTP status.
63    #[error("runner {endpoint} returned status {status}: {body}")]
64    NonSuccessStatus {
65        /// Endpoint path that returned the error.
66        endpoint: String,
67        /// HTTP status code.
68        status: u16,
69        /// Raw response body (may be truncated).
70        body: String,
71    },
72    /// Runner returned a body that wasn't valid JSON.
73    #[error("runner {endpoint} returned non-JSON body")]
74    NonJsonBody {
75        /// Endpoint path that returned a non-JSON body.
76        endpoint: String,
77    },
78    /// Runner returned valid JSON but it didn't match the expected schema.
79    #[error("runner {endpoint} returned malformed body: {detail}")]
80    MalformedBody {
81        /// Endpoint path that returned the malformed body.
82        endpoint: String,
83        /// Schema-mismatch detail (serde error message).
84        detail: String,
85    },
86    /// Runner is unreachable (refused / closed / not listening).
87    #[error("runner {endpoint} unreachable: {message}")]
88    Unreachable {
89        /// Endpoint path attempted.
90        endpoint: String,
91        /// Reason (e.g. "connection refused").
92        message: String,
93    },
94}
95
96// -------------------- Common wire types ---------------------------------
97
98/// `include` scope query param shared by `/tree` / `/tap` / `/fill` /
99/// `/clear` / `/find` / `/scroll` / `/system-popups`. URL-only.
100#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct RunnerIncludeOpts {
103    /// Optional include scope (e.g. system-popups → `AllWindows`).
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub include: Option<IncludeScope>,
106}
107
108/// `include` scope literal — currently only `all-windows` (system popups
109/// pierce the app frame).
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "kebab-case")]
112pub enum IncludeScope {
113    /// Include all windows (system popups, alerts) above the app frame.
114    AllWindows,
115}
116
117impl IncludeScope {
118    /// kebab-case wire string used in the query parameter value.
119    pub fn query_value(self) -> &'static str {
120        match self {
121            IncludeScope::AllWindows => "all-windows",
122        }
123    }
124}
125
126// -------------------- /tap wire shape -----------------------------------
127
128/// `POST /tap` mode discriminator.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "camelCase")]
131pub enum TapMode {
132    /// Runner returns only the matched frame; host normalizes + injects
133    /// via `/tap-at-norm-coord` (v1.6 c5 default).
134    Resolve,
135    /// Runner resolves AND taps (legacy v1.1 path A, host-HID-based).
136    ResolveAndTap,
137    /// Runner resolves selector then synthesizes the touch event via the
138    /// XCTRunnerDaemonSession daemonProxy (v4.0 c3 swift G8 fix —
139    /// bypasses the XCUIElement gesture recognizer chain so RN
140    /// Pressable `RCTTouchHandler` UIGestureRecognizer receives the
141    /// touch and fires the JS-thread `onPress` callback reliably).
142    DaemonProxySynthesize,
143}
144
145/// `POST /tap` per-stage timing in milliseconds.
146#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub struct TapStages {
149    /// Time spent resolving the selector against the a11y tree.
150    #[serde(rename = "resolveMs", default)]
151    pub resolve_ms: f64,
152    /// Time spent dispatching the tap event itself.
153    #[serde(rename = "tapCallMs", default)]
154    pub tap_call_ms: f64,
155    /// End-to-end wall-clock for the whole tap call.
156    #[serde(rename = "totalMs", default)]
157    pub total_ms: f64,
158    /// Time spent waiting for the element to exist (implicit wait).
159    #[serde(rename = "waitExistenceMs", default)]
160    pub wait_existence_ms: f64,
161    /// Time spent reading the matched element's frame.
162    #[serde(rename = "frameReadMs", default)]
163    pub frame_read_ms: f64,
164}
165
166/// `POST /tap` response body.
167#[derive(Clone, Debug, Default, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct TapResult {
170    /// Per-stage timing breakdown.
171    #[serde(default)]
172    pub stages: Option<TapStages>,
173    /// Matched element's geometric frame, when resolve succeeded.
174    #[serde(default)]
175    pub frame: Option<smix_screen::Rect>,
176    /// Application window frame (for normalizing the matched frame).
177    #[serde(rename = "appFrame", default)]
178    pub app_frame: Option<smix_screen::Rect>,
179}
180
181/// `POST /tap` request body.
182#[derive(Clone, Debug, Serialize, Deserialize)]
183#[serde(rename_all = "camelCase")]
184pub struct TapRequest {
185    /// Selector picking the tap target.
186    pub selector: Selector,
187    /// Resolve-only vs resolve-and-tap discriminator.
188    pub mode: TapMode,
189}
190
191/// `POST /tap-at-norm-coord` request body.
192#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
193#[serde(rename_all = "camelCase")]
194pub struct TapAtNormCoordRequest {
195    /// Normalized x coordinate in `(0, 1)` (app-frame relative).
196    pub nx: f64,
197    /// Normalized y coordinate in `(0, 1)` (app-frame relative).
198    pub ny: f64,
199}
200
201// -------------------- /fill /clear /press-key wire shape ----------------
202
203/// Per-stage timing returned by `/fill` / `/clear` / `/press-key`.
204#[derive(Clone, Debug, Default, Serialize, Deserialize)]
205#[serde(rename_all = "camelCase")]
206pub struct KeyboardStages {
207    /// Selector resolve time.
208    #[serde(rename = "resolveMs", default)]
209    pub resolve_ms: f64,
210    /// Time waiting for keyboard appearance after the focus tap.
211    #[serde(rename = "keyboardWaitMs", default)]
212    pub keyboard_wait_ms: f64,
213    /// Time spent typing the characters.
214    #[serde(rename = "typingMs", default)]
215    pub typing_ms: f64,
216    /// End-to-end wall-clock for the whole keyboard operation.
217    #[serde(rename = "totalMs", default)]
218    pub total_ms: f64,
219}
220
221/// `POST /fill` / `POST /clear` / `POST /press-key` response body.
222#[derive(Clone, Debug, Default, Serialize, Deserialize)]
223#[serde(rename_all = "camelCase")]
224pub struct RunnerKeyboardResult {
225    /// Tree snapshot after the keyboard operation completed (optional).
226    #[serde(default)]
227    pub tree: Option<smix_screen::A11yNode>,
228    /// Per-stage timing breakdown (optional).
229    #[serde(default)]
230    pub stages: Option<KeyboardStages>,
231}
232
233// -------------------- /find wire shape -----------------------------------
234
235/// `POST /find` request body.
236#[derive(Clone, Debug, Serialize, Deserialize)]
237#[serde(rename_all = "camelCase")]
238pub struct FindRequest {
239    /// Selector to look up.
240    pub selector: Selector,
241}
242
243/// `POST /find` response body.
244#[derive(Clone, Debug, Default, Serialize, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct FindResponse {
247    /// Whether the selector matched any element.
248    #[serde(default)]
249    pub exists: bool,
250    /// Whether the resolve subsystem itself succeeded.
251    #[serde(default)]
252    pub ok: bool,
253}
254
255// -------------------- /scroll wire shape --------------------------------
256
257/// Reduced selector shape used by `/scroll` (text-or-id only; complex
258/// selectors are host-side-resolved before reaching the runner).
259#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
260#[serde(untagged)]
261pub enum RunnerScrollSelector {
262    /// Match by text content.
263    Text {
264        /// Text to match against.
265        text: String,
266    },
267    /// Match by accessibility identifier.
268    Id {
269        /// Identifier to match against.
270        id: String,
271    },
272}
273
274/// `POST /scroll` response body.
275#[derive(Clone, Debug, Default, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct ScrollResponse {
278    /// Whether the selector matched after scrolling (None when unknown).
279    #[serde(default)]
280    pub matched: Option<bool>,
281    /// Number of swipe iterations performed.
282    #[serde(default)]
283    pub swipes: Option<u32>,
284}
285
286// -------------------- /system-popups wire shape -------------------------
287
288/// One system-popup discovered on the screen (alert / sheet / banner).
289#[derive(Clone, Debug, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct SystemPopup {
292    /// Stable identifier for matching across polls.
293    pub id: String,
294    /// Discriminator (e.g. `"alert"` / `"sheet"` / `"banner"`).
295    #[serde(rename = "type")]
296    pub kind: String,
297    /// Originator (e.g. bundle id, system framework name).
298    pub source: String,
299    /// Popup title text.
300    #[serde(default)]
301    pub title: String,
302    /// Popup body text.
303    #[serde(default)]
304    pub body: String,
305    /// Buttons available on the popup.
306    #[serde(default)]
307    pub buttons: Vec<SystemPopupButton>,
308}
309
310/// One button on a [`SystemPopup`].
311#[derive(Clone, Debug, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct SystemPopupButton {
314    /// Stable identifier for the button.
315    pub id: String,
316    /// Visible button label.
317    pub label: String,
318    /// Semantic role (`"cancel"` / `"destructive"` / `"default"`).
319    pub role: String,
320    /// Whether tapping this button performs a destructive action.
321    #[serde(default)]
322    pub dangerous: bool,
323    /// Optional outcome hint (e.g. `"grants location permission"`).
324    #[serde(rename = "outcomeHint", default)]
325    pub outcome_hint: Option<String>,
326}
327
328/// `POST /system-popup-action` request body (v4.2 c2 — G9 act side).
329///
330/// `popupId` and `buttonId` round-trip from a prior `GET /system-popups`
331/// enumerate (`Popup.id` / `PopupButton.id` fields). The runner walks the
332/// same scan order on the act path, so callers do not need to maintain an
333/// out-of-band id map.
334#[derive(Clone, Debug, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct SystemPopupActionRequest {
337    /// Popup id from a prior `Popup.id` enumerate.
338    pub popup_id: String,
339    /// Button id from a prior `PopupButton.id` enumerate.
340    pub button_id: String,
341}
342
343/// `POST /system-popup-action` response body.
344///
345/// `ok=true` ⇒ the runner found the popup + button and dispatched a
346/// daemonProxy touch; `ok=false` ⇒ neither side matched (either popup id
347/// missed, button id missed, or synthesize raised inside the runner).
348#[derive(Clone, Debug, Default, Serialize, Deserialize)]
349#[serde(rename_all = "camelCase")]
350pub struct SystemPopupActionResponse {
351    /// Whether the popup-button match + tap dispatch succeeded.
352    #[serde(default)]
353    pub ok: bool,
354    /// Wire-layer error discriminator ("not_found" / "bad_request" / etc.)
355    /// emitted by the runner on the non-2xx path. Absent on success.
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub error: Option<String>,
358}
359
360/// `GET /system-popups` response body.
361#[derive(Clone, Debug, Default, Serialize, Deserialize)]
362#[serde(rename_all = "camelCase")]
363pub struct SystemPopupsResponse {
364    /// All popups currently on screen (empty when none).
365    #[serde(default)]
366    pub popups: Vec<SystemPopup>,
367}
368
369// -------------------- /record/* wire shape ------------------------------
370
371/// One recorder event captured by `/record/start` → `/record/poll` flow.
372///
373/// v5.1 c3 S2 校正:swift `EventRecorder` 端实际 emit 的是 `rawCode` field
374/// (kAXNotification raw int — 1018 = focus change / 1028 = HID / 4002 = userTesting / ...),
375/// 不是 `code`。c2 capstone 初版 jq 用 `.code` 查 events.json 全返 null,
376/// 误判 "0 个 1018",修正后真实有 3 × 1018。SDK 端 deserialize 之前同样
377/// silent → `code` 字段永远 0。本 struct 字段名按 swift 真实 schema 对齐
378/// (`raw_code` + serde camelCase → `rawCode`),并加 `extra` flatten 兜底
379/// 把 swift 在 RecordedEvent 顶层平铺的 enrich 字段(`kind` / `frame` /
380/// `payloadDescription` / `elementType` / 等)宽松接收。
381#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct RecordedEvent {
384    /// Numeric event-type discriminator(swift `RecordedEvent.rawCode`)。
385    /// 典型值:1018 (kAXFirstResponderChangedNotification) / 1028
386    /// (kAXHIDEventReceivedNotification) / 4002 (kAXUserTestingNotification)
387    /// / 1006 (kAXAlertNotification) / 1021 (kAXPidStatusChangedNotification)。
388    #[serde(default)]
389    pub raw_code: i32,
390    /// Capture-time timestamp in milliseconds.
391    #[serde(default)]
392    pub timestamp_ms: f64,
393    /// Free-form per-event 顶层 enrich 字段(swift 端平铺 `kind` / `frame` /
394    /// `payloadDescription` / `elementType` / `appBundleId` / `payloadClassName`
395    /// 等)。reconcile 只读 `raw_code` + `timestamp_ms`,不依赖此字段;
396    /// 上层 SDK 想看明细时按需取(类型 = `serde_json::Map`)。
397    #[serde(flatten, default)]
398    pub extra: serde_json::Map<String, serde_json::Value>,
399}
400
401/// `GET /record/poll` / `POST /record/stop` response body.
402#[derive(Clone, Debug, Default, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct RecordEventsResponse {
405    /// Events captured since the last poll (chronological).
406    #[serde(default)]
407    pub events: Vec<RecordedEvent>,
408}
409
410// -------------------- /session/* wire shape (v1.0.2) --------------------
411//
412// Session lifecycle addresses the "activation storm" root cause: pre-v1.0.2
413// runners re-bind + `.activate()` an `XCUIApplication` on every request
414// whose `App-Activate: true` header is set. Long-running gates (visual /
415// perf regression) accumulate thousands of activate calls, exhausting
416// XCTest process arbitration on iOS 26.5+ and crashing `test_runForever()`
417// mid-run. Sessions replace that with a one-shot lifecycle: open once,
418// runner caches the binding + activates at most on transition or via
419// explicit renew, close when the client is done.
420//
421// Wire compat: absent `Session-Id: <id>` header on any request falls
422// through to the legacy per-request `resolveApp()` path, now itself
423// rate-limited to at most one `.activate()` per 5 s per bundle-id (which
424// is enough to keep the recovery-from-drift semantic of the original
425// design without producing the storm).
426
427/// `POST /session/open` request body.
428#[derive(Clone, Debug, Serialize, Deserialize)]
429#[serde(rename_all = "camelCase")]
430pub struct SessionOpenRequest {
431    /// Bundle id (iOS) / package name (Android) the session is bound to.
432    /// Empty string means "use the runner's boot-time default", which is
433    /// almost always `com.apple.Preferences` — usable for testing but
434    /// probably not what the client wants.
435    #[serde(default)]
436    pub bundle_id: String,
437    /// If true, the runner calls `.activate()` once as part of the open,
438    /// synchronously, before returning. Idiomatic for gates that want
439    /// the target app foregrounded before the first `/tap` fires.
440    #[serde(default)]
441    pub activate: bool,
442}
443
444/// `POST /session/open` response body.
445///
446/// The returned `session_id` becomes the value of the `Session-Id`
447/// request header on every subsequent request that should share this
448/// session's cached app binding.
449#[derive(Clone, Debug, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451pub struct SessionOpenResponse {
452    /// Opaque token; treat as a stable string identifier. UUID today.
453    pub session_id: String,
454    /// Whether the runner issued an initial `.activate()` on open.
455    /// Mirrors the request's `activate` field unless the runner
456    /// rate-limited it (e.g. same bundle-id was activated within the
457    /// last 5 s from a prior session).
458    #[serde(default)]
459    pub activated_once: bool,
460    /// Server-side epoch millis at open. Consumers pair this with
461    /// downstream `sessionUptimeMs` sidecar fields to reconstruct
462    /// session timelines.
463    #[serde(default)]
464    pub server_time_ms: u64,
465}
466
467/// `POST /session/close` request body.
468#[derive(Clone, Debug, Serialize, Deserialize)]
469#[serde(rename_all = "camelCase")]
470pub struct SessionCloseRequest {
471    /// Session to close. Absent / unknown / already-closed sessions
472    /// return 200 with `ok=true` — idempotent.
473    pub session_id: String,
474}
475
476/// `POST /session/close` response body.
477#[derive(Clone, Debug, Default, Serialize, Deserialize)]
478#[serde(rename_all = "camelCase")]
479pub struct SessionCloseResponse {
480    /// True unless the runner failed to remove the session cache entry.
481    /// Not a "session was known" flag — closing an unknown session is
482    /// idempotent success.
483    pub ok: bool,
484}
485
486/// `POST /session/renew-activation` request body.
487///
488/// Client-side escape hatch when the client detects target-app drift
489/// (e.g. `/tree` returned `snapshot_unavailable`, or a foreground steal
490/// by SpringBoard). The runner re-issues `.activate()` on the cached
491/// binding subject to the same 5 s / bundle-id rate limit.
492#[derive(Clone, Debug, Serialize, Deserialize)]
493#[serde(rename_all = "camelCase")]
494pub struct SessionRenewActivationRequest {
495    /// Session to renew. Unknown session → 404.
496    pub session_id: String,
497}
498
499/// `POST /session/renew-activation` response body.
500#[derive(Clone, Debug, Default, Serialize, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct SessionRenewActivationResponse {
503    /// True on success (session known + not-rate-limited).
504    pub ok: bool,
505    /// Whether the runner actually issued `.activate()` this call, or
506    /// suppressed it because the previous activation was within the
507    /// rate-limit window. `ok=true, activated=false` is a valid outcome
508    /// meaning "session is fresh enough, no-op".
509    #[serde(default)]
510    pub activated: bool,
511}
512
513/// Extended `GET /health` response body (v1.0.2 additive).
514///
515/// Prior to v1.0.2 the endpoint returned a bare 200 with no body.
516/// Consumers that ignore the body get identical behavior; consumers
517/// that parse the JSON gain liveness observability.
518#[derive(Clone, Debug, Default, Serialize, Deserialize)]
519#[serde(rename_all = "camelCase")]
520pub struct HealthResponse {
521    /// Runner alive.
522    pub ok: bool,
523    /// Runner semver (matches the smix-cli that shipped it).
524    #[serde(default)]
525    pub runner_version: String,
526    /// Runner uptime in milliseconds.
527    #[serde(default)]
528    pub uptime_ms: u64,
529    /// Epoch millis of the runner's most recent processed request
530    /// (any route). 0 if the runner has served no requests yet.
531    #[serde(default)]
532    pub last_request_at_ms: u64,
533    /// Currently-open session count.
534    #[serde(default)]
535    pub sessions_open: u32,
536    /// Total `.activate()` calls issued since runner boot.
537    #[serde(default)]
538    pub activations_total: u64,
539    /// v1.0.4 — SimRenderServer pid + alive flag observed by the
540    /// runner's sim-health sensor. `alive = false` after
541    /// `com.apple.display.captureservice` internal assertion trips.
542    #[serde(default)]
543    pub sim_render_server: HealthProcessInfo,
544    /// v1.0.4 — xcodebuild test-host pid + alive flag + total restart
545    /// count (S7 auto-restart on `** TEST INTERRUPTED **`).
546    #[serde(default)]
547    pub xcodebuild_test_host: HealthTestHostInfo,
548}
549
550/// v1.0.4 — pid + alive flag for a watched process. Additive to
551/// [`HealthResponse`].
552#[derive(Clone, Debug, Default, Serialize, Deserialize)]
553#[serde(rename_all = "camelCase")]
554pub struct HealthProcessInfo {
555    /// True when the runner last observed the process alive.
556    #[serde(default)]
557    pub alive: bool,
558    /// Last-known pid (0 if never observed).
559    #[serde(default)]
560    pub pid: u32,
561}
562
563/// v1.0.4 — xcodebuild test-host health with restart accounting.
564#[derive(Clone, Debug, Default, Serialize, Deserialize)]
565#[serde(rename_all = "camelCase")]
566pub struct HealthTestHostInfo {
567    /// True when the runner-side supervisor last saw xcodebuild alive.
568    #[serde(default)]
569    pub alive: bool,
570    /// Last-known pid (0 if unrecorded).
571    #[serde(default)]
572    pub pid: u32,
573    /// Number of times the supervisor has restarted the test-host
574    /// (RFC 1.0.4 §D6).
575    #[serde(default)]
576    pub restart_count: u32,
577}
578
579// ---- v1.0.4 D5 / D6 / D7 / D14 additive wire ----------------------------
580
581/// `POST /session/close-all` — v1.0.4 §D5 support for `smix runner
582/// cycle`. Runner-side clears every open session and returns the
583/// count that was cleared.
584#[derive(Clone, Debug, Default, Serialize, Deserialize)]
585#[serde(rename_all = "camelCase")]
586pub struct SessionCloseAllResponse {
587    /// True on success (idempotent — closing an empty session table
588    /// is not an error).
589    pub ok: bool,
590    /// Number of sessions closed.
591    #[serde(default)]
592    pub closed: u32,
593}
594
595/// `POST /session/relaunch-app` request body (v1.0.4 §D14).
596///
597/// Instructs the runner to `terminate()` + `launch()` the session's
598/// cached `XCUIApplication` binding IN PLACE — same test-host process,
599/// same session id, same XCUITest binding. Used to recover from a
600/// downstream app crash without cycling the entire runner.
601#[derive(Clone, Debug, Serialize, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct SessionRelaunchAppRequest {
604    /// Session whose cached binding will be relaunched.
605    pub session_id: String,
606}
607
608/// `POST /session/relaunch-app` response body.
609#[derive(Clone, Debug, Default, Serialize, Deserialize)]
610#[serde(rename_all = "camelCase")]
611pub struct SessionRelaunchAppResponse {
612    /// True on success (session known + relaunch cycle completed).
613    pub ok: bool,
614    /// Wall-clock milliseconds the terminate+launch cycle took.
615    #[serde(default)]
616    pub wall_ms: u64,
617}
618
619/// v1.0.8 §D1 — request body shared by `POST /session/terminate-app`
620/// and `POST /session/launch-app`. v1.0.11 §D2 — extended with launch-
621/// argument / launch-environment injection so consumers can bypass
622/// scaffolding like Expo dev-launcher server pickers that vanish on
623/// clearAppData (SDK 57 stops auto-navigating on URL scheme).
624// Not `#[non_exhaustive]` — consumers construct these directly. Wire
625// evolution is handled by `#[serde(default)]` on each field, so
626// adding a field is source-compatible for anyone using
627// `..Default::default()` in their struct literal (idiomatic pattern
628// in the SDK). The response variant DOES carry `#[non_exhaustive]`
629// because consumers only READ it, not construct.
630#[derive(Clone, Debug, Default, Serialize, Deserialize)]
631#[serde(rename_all = "camelCase")]
632pub struct SessionAppLifecycleRequest {
633    /// Session whose cached `XCUIApplication` binding will be acted on.
634    pub session_id: String,
635    /// v1.0.11 §D2 — launchArguments passed to `XCUIApplication.launch()`.
636    /// Empty vec = pre-v1.0.11 behavior (whatever the runner already had
637    /// cached on `entry.app.launchArguments`, which is empty by default).
638    /// Ignored by `/session/terminate-app`.
639    #[serde(default)]
640    pub args: Vec<String>,
641    /// v1.0.11 §D2 — launchEnvironment passed to `XCUIApplication.launch()`.
642    /// Empty map = pre-v1.0.11 behavior. Ignored by
643    /// `/session/terminate-app`.
644    #[serde(default)]
645    pub env: std::collections::BTreeMap<String, String>,
646    /// v1.0.11 §D3 — after `.launch()` dispatches, poll
647    /// `XCUIApplication.state` at 250 ms cadence until `.runningForeground`
648    /// (or timeout). Prevents callers from firing a subsequent
649    /// terminateApp before the process has signalled launchd ready — the
650    /// root cause of `bug_type: 309 exec_terminated_before_ready` `.ips`
651    /// writes insight reported on v1.0.10. `None` = pre-v1.0.11 fire-
652    /// and-return semantics; `Some(0)` = wait one iteration then return
653    /// terminal state (useful for tests). Default: `Some(15000)` at the
654    /// SDK layer.
655    #[serde(default)]
656    pub wait_for_foreground_ms: Option<u64>,
657    /// v1.0.15 Cluster C D1 — after `.runningForeground` is observed
658    /// (or immediately when `wait_for_foreground_ms == None`), poll
659    /// the a11y tree at 500 ms cadence looking for ≥ `minIdentifierCount`
660    /// descendants with a non-empty `accessibilityIdentifier` NOT in
661    /// the interactive-probe ignore list. Fires
662    /// `launchAppReachedInteractive` when found; on timeout fires
663    /// `launchAppTimedOutBeforeInteractive`. Both counters live on
664    /// [`SessionLifecycleCounters`] and were wire-scaffolded in v1.0.14.
665    ///
666    /// `None` = pre-v1.0.15 fire-and-return (no interactive polling).
667    /// Consumer config `.smix/config.yaml interactiveProbe: { minIdentifierCount, ignore: [...] }`
668    /// forwards to the runner via `TEST_RUNNER_SMIX_INTERACTIVE_PROBE_JSON`
669    /// at boot. Q7 answers in `smix-feedback-2026-07-11-v1.0.12-answers.md`
670    /// seeded defaults: `minIdentifierCount: 3`, `ignore: [SplashScreenLogo,
671    /// com.focusai.app.mobile]`.
672    #[serde(default)]
673    pub wait_for_interactive_ms: Option<u64>,
674}
675
676/// v1.0.8 §D1 — response for `POST /session/terminate-app` and
677/// `POST /session/launch-app`. v1.0.11 §D3 — extended with the
678/// observed terminal `.state` and the wall time spent waiting for
679/// foreground.
680#[derive(Clone, Debug, Default, Serialize, Deserialize)]
681#[serde(rename_all = "camelCase")]
682#[non_exhaustive]
683pub struct SessionAppLifecycleResponse {
684    /// True on success.
685    pub ok: bool,
686    /// Wall-clock milliseconds the terminate or launch took.
687    #[serde(default)]
688    pub wall_ms: u64,
689    /// v1.0.11 §D3 — Wall-clock milliseconds spent inside the wait-
690    /// for-foreground loop. Zero when the caller passed
691    /// `wait_for_foreground_ms: 0` or when the runner is pre-v1.0.11.
692    #[serde(default)]
693    pub waited_ms: u64,
694    /// v1.0.11 §D3 — final observed `XCUIApplication.state` at the
695    /// moment the runner returned. Wire-encoded as the raw `Int`
696    /// value XCTest uses: `0` unknown, `1` notRunning, `2` runningBackgroundSuspended,
697    /// `3` runningBackground, `4` runningForeground. Zero when
698    /// pre-v1.0.11 or when the caller opted out.
699    #[serde(default)]
700    pub terminal_state: u8,
701    /// v1.0.11 §D5 — set when the runner observed cooperative
702    /// termination (`XCUIApplication.terminate()` observed
703    /// `.notRunning` before timeout). `false` on `terminate-app` when
704    /// the runner had to fall back to a hard signal.
705    /// Only meaningful on `terminate-app` responses; `false` on
706    /// `launch-app`.
707    #[serde(default)]
708    pub terminated_cooperatively: bool,
709    /// v1.0.15 Cluster C D1 — set on `launch-app` responses when the
710    /// interactive-probe (≥ `minIdentifierCount` non-empty ax-ids
711    /// outside the ignore list) fired inside `wait_for_interactive_ms`.
712    /// `false` when the caller didn't set `wait_for_interactive_ms`
713    /// OR when polling timed out without observing the interactive
714    /// fingerprint.
715    #[serde(default)]
716    pub reached_interactive: bool,
717    /// v1.0.15 Cluster C D1 — sample of up to 8 `accessibilityIdentifier`
718    /// values observed at the moment `reached_interactive` fired.
719    /// Non-normative; useful for consumers debugging "did my
720    /// interactive probe fire on the right screen". Empty when
721    /// `reached_interactive == false` or the caller didn't set
722    /// `wait_for_interactive_ms`.
723    #[serde(default)]
724    pub interactive_named_ids: Vec<String>,
725}
726
727/// v1.0.5 §D1 — one entry in `POST /session/list` response.
728#[derive(Clone, Debug, Serialize, Deserialize)]
729#[serde(rename_all = "camelCase")]
730pub struct SessionSummary {
731    /// Session id.
732    pub session_id: String,
733    /// Bundle id the session's XCUIApplication is bound to.
734    pub bundle_id: String,
735    /// Epoch millis of open time.
736    #[serde(default)]
737    pub opened_at_ms: u64,
738    /// Epoch millis of last `.activate()` on this session (renew or
739    /// open). Distinct from the runner-internal `lastAccessedAt` used
740    /// by the idle-close sweep.
741    #[serde(default)]
742    pub last_activated_at_ms: u64,
743}
744
745/// `POST /session/list` response body (v1.0.5 §D1).
746#[derive(Clone, Debug, Default, Serialize, Deserialize)]
747#[serde(rename_all = "camelCase")]
748pub struct SessionListResponse {
749    /// Every session currently known to the runner.
750    #[serde(default)]
751    pub sessions: Vec<SessionSummary>,
752}
753
754/// v1.0.7 §D3 — one entry in the subprocess ring buffer.
755#[derive(Clone, Debug, Serialize, Deserialize)]
756#[serde(rename_all = "camelCase")]
757pub struct SubprocessRecord {
758    /// argv passed to the invoked binary.
759    pub argv: Vec<String>,
760    /// Exit code; `None` when the process failed to spawn.
761    #[serde(default)]
762    pub exit_code: Option<i32>,
763    /// Wall-clock milliseconds.
764    #[serde(default)]
765    pub wall_ms: u64,
766    /// First 256 bytes of stderr.
767    #[serde(default)]
768    pub stderr_head: String,
769    /// Epoch millis when the invocation completed.
770    #[serde(default)]
771    pub timestamp_ms: u64,
772}
773
774/// v1.0.10 §D5 / v1.0.11 §D1 — app-alive cache observability counters.
775/// Always emitted (even when the runner was booted without an
776/// `appAliveCache` — `wired: false` sentinel + all-zero counters).
777/// Consumers use `wired` to distinguish "workload never fired the
778/// re-probe path" from "runner version predates this observability".
779#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
780#[serde(rename_all = "camelCase")]
781#[non_exhaustive]
782pub struct AliveCacheCounters {
783    /// `false` sentinel — set explicitly by pre-v1.0.11 runners that
784    /// omit the field or by v1.0.11+ runners booted without a cache.
785    /// When `true`, the field values reflect an actual live cache.
786    #[serde(default)]
787    pub wired: bool,
788    /// Total calls to `AppAliveCache::markDead`.
789    #[serde(default)]
790    pub mark_dead_total: u64,
791    /// Total calls to `AppAliveCache::markAlive` (explicit + re-probe-success).
792    #[serde(default)]
793    pub mark_alive_total: u64,
794    /// `isSuppressed` returned true — a call was short-circuited.
795    #[serde(default)]
796    pub suppress_hit_total: u64,
797    /// `isSuppressed` returned false — a call was allowed through.
798    #[serde(default)]
799    pub suppress_miss_total: u64,
800    /// v1.0.9 §D4 re-probe Task spawned.
801    #[serde(default)]
802    pub reprobe_attempted_total: u64,
803    /// Re-probe observed target return to running.
804    #[serde(default)]
805    pub reprobe_succeeded_total: u64,
806    /// Re-probe was invalidated mid-flight by external markAlive.
807    #[serde(default)]
808    pub reprobe_invalidated_early: u64,
809    /// Re-probe ran all 6 iterations without recovery.
810    #[serde(default)]
811    pub reprobe_exhausted_window: u64,
812}
813
814/// v1.0.11 §D3 / §D4 / §D5 — cumulative session lifecycle counters.
815/// Unlike the instantaneous `sessions: Vec<SessionSummary>` view,
816/// these are advanced on every mutation and survive `close`. Answers
817/// insight's v1.0.10 followup question "did clearAppData actually use
818/// the cooperative pathway or fall back to SIGKILL" via the split
819/// `terminate_app_via_xcuiapplication` / `terminate_app_via_fallback`
820/// counters.
821#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
822#[serde(rename_all = "camelCase")]
823#[non_exhaustive]
824pub struct SessionLifecycleCounters {
825    /// Total `POST /session/open` outcomes.
826    #[serde(default)]
827    pub opened_total: u64,
828    /// Total `POST /session/close` outcomes (idempotent — includes closes
829    /// of unknown sessions).
830    #[serde(default)]
831    pub closed_total: u64,
832    /// Total `POST /session/relaunch-app` outcomes.
833    #[serde(default)]
834    pub relaunch_app_total: u64,
835    /// Total `POST /session/terminate-app` outcomes.
836    #[serde(default)]
837    pub terminate_app_total: u64,
838    /// Terminate calls that observed the target `.notRunning` before
839    /// the XCUIApplication timeout — cooperative pathway succeeded.
840    #[serde(default)]
841    pub terminate_app_via_xcuiapplication: u64,
842    /// Terminate calls where XCUIApplication timed out and the runner
843    /// fell back to a hard-kill pathway. `> 0` is the smoking-gun
844    /// insight asked us to expose in the v1.0.10 followup.
845    #[serde(default)]
846    pub terminate_app_via_fallback: u64,
847    /// Total `POST /session/launch-app` outcomes.
848    #[serde(default)]
849    pub launch_app_total: u64,
850    /// Launch calls that observed `.runningForeground` inside the
851    /// requested `wait_for_foreground_ms` window.
852    #[serde(default)]
853    pub launch_app_reached_foreground: u64,
854    /// Launch calls that timed out waiting for foreground.
855    #[serde(default)]
856    pub launch_app_timed_out_before_foreground: u64,
857    /// v1.0.14 Cluster A — total `resetAppData` verbs dispatched
858    /// (URL-scheme JS-wipe pattern, separate from `clearAppData`'s
859    /// container-wipe pathway).
860    #[serde(default)]
861    pub reset_app_data_total: u64,
862    /// v1.0.14 Cluster A — resetAppData calls where the completion
863    /// signal (log-line pattern match on the metro log tail) never
864    /// arrived inside the timeout window. `> 0` = the URL fired but
865    /// the app didn't emit the expected `[dev] reset-complete` line.
866    #[serde(default)]
867    pub reset_app_data_timed_out: u64,
868    /// v1.0.14 Cluster C — launch calls that observed the
869    /// interactive fingerprint (`≥ minIdentifierCount` non-ignored
870    /// ax-ids in the a11y tree) inside `wait_for_interactive_ms`.
871    /// Distinct from `launch_app_reached_foreground` which only
872    /// tracks process state, not tree probeability.
873    #[serde(default)]
874    pub launch_app_reached_interactive: u64,
875    /// v1.0.14 Cluster C — launch calls that timed out waiting for
876    /// the interactive fingerprint. Non-zero = "process foreground
877    /// but tree unusable" = the case insight's v1.0.11 followup
878    /// observed on their bootstrap batch (SplashScreenLogo + all
879    /// unknown descendants).
880    #[serde(default)]
881    pub launch_app_timed_out_before_interactive: u64,
882}
883
884/// v1.0.14 §6 — per-flow attempt attribution. Populated by
885/// `smix run --retry <N>` (default 1) as each flow completes; carries
886/// through `/diagnostic/dump` so consumers can attribute a `.ips` to
887/// a specific retry rather than a whole batch.
888#[derive(Clone, Debug, Default, Serialize, Deserialize)]
889#[serde(rename_all = "camelCase")]
890#[non_exhaustive]
891pub struct FlowAttemptRecord {
892    /// Flow name (yaml basename or explicit id).
893    #[serde(default)]
894    pub flow_name: String,
895    /// Ordered attempts. Retry #0 is the first try; retry #1 is
896    /// second; etc.
897    #[serde(default)]
898    pub attempts: Vec<FlowAttempt>,
899}
900
901/// One attempt within a flow (see [`FlowAttemptRecord`]).
902#[derive(Clone, Debug, Default, Serialize, Deserialize)]
903#[serde(rename_all = "camelCase")]
904#[non_exhaustive]
905pub struct FlowAttempt {
906    /// Zero-based retry index. `0` = first try.
907    #[serde(default)]
908    pub attempt_index: u32,
909    /// Overall outcome: "ok" / "timeout" / "error" / "crashed".
910    #[serde(default)]
911    pub status: String,
912    /// Free-form error class code when non-ok (e.g., "TIMEOUT",
913    /// "DRIVER_ERROR", "APP_UNAVAILABLE"). Empty on success.
914    #[serde(default)]
915    pub error_class: Option<String>,
916    /// If a `.ips` file appeared under `~/Library/Logs/DiagnosticReports/`
917    /// during this attempt's window, its filename. Consumers grep for
918    /// this to tie a crash-report to a specific retry.
919    #[serde(default)]
920    pub ips_generated: Option<String>,
921    /// Wall-clock milliseconds this attempt ran.
922    #[serde(default)]
923    pub wall_ms: u64,
924}
925
926/// `POST /diagnostic/dump` response body (v1.0.7 §D5, extended
927/// v1.0.11 §D1/§D4/§D5).
928///
929/// Snapshot of the runner's runtime state: recent subprocess
930/// invocations, currently-open sessions, sim health state, supervisor
931/// pid, plus the always-emitted app-alive cache + cumulative session
932/// lifecycle counters that answer "is the observability instrumented?"
933/// as a numeric question, not a grep for a log line that may or may
934/// not survive a supervisor cycle.
935#[derive(Clone, Debug, Default, Serialize, Deserialize)]
936#[serde(rename_all = "camelCase")]
937#[non_exhaustive]
938pub struct DiagnosticDumpResponse {
939    /// Recent `xcrun simctl` invocations recorded by the runner's
940    /// subprocess ring buffer. Ordered oldest → newest, capped at 128.
941    #[serde(default)]
942    pub recent_subprocesses: Vec<SubprocessRecord>,
943    /// Every session currently known to the runner (mirrors
944    /// `/session/list`; embedded here for one-shot dump).
945    #[serde(default)]
946    pub sessions: Vec<SessionSummary>,
947    /// Coarse sim-health classification at dump time.
948    #[serde(default)]
949    pub sim_health: String,
950    /// Pid of the supervisor sidecar, when spawned via
951    /// `smix runner up --supervise` (v1.0.6 §D1).
952    #[serde(default)]
953    pub supervisor_pid: Option<u32>,
954    /// Runner wall-clock uptime in milliseconds.
955    #[serde(default)]
956    pub uptime_ms: u64,
957    /// v1.0.11 §D1 — always-present. `wired=false` when the runner
958    /// didn't have an `AppAliveCache` wired at boot (opt-out) or
959    /// predates this observability. When `wired=true`, the counter
960    /// fields reflect an actual live cache — even all-zero is
961    /// meaningful ("workload didn't fire the re-probe path").
962    #[serde(default)]
963    pub alive_cache: AliveCacheCounters,
964    /// v1.0.11 §D4/§D5 — cumulative session lifecycle counters. Advance
965    /// on every mutation, survive close, persisted alongside the
966    /// v1.0.5 session-persistence file so `smix runner cycle` doesn't
967    /// wipe them.
968    #[serde(default)]
969    pub session_counters: SessionLifecycleCounters,
970    /// v1.0.14 Cluster B / §5 — trailing N lines from the external
971    /// metro log the CLI was told to tail (via `--metro-log <path>`
972    /// on `smix run` or `smix diagnostic dump`). Empty when the CLI
973    /// was not given a log path OR when the file was unreadable at
974    /// dump time. Default N is 200 lines; consumer overrides with
975    /// `--metro-log-tail-lines <N>`. Purely additive — a pre-v1.0.14
976    /// consumer ignoring this field sees no behavior change.
977    #[serde(default)]
978    pub metro_log_tail: Vec<String>,
979    /// v1.0.14 §6 — retry-attribution roll-up. Populated by
980    /// `smix run` when it has driven at least one flow. Each entry
981    /// carries per-attempt status + errorClass + any `.ips` generated
982    /// so consumers can tell "flow X passed on retry 2 after retry 1
983    /// crashed" from "flow X passed on the first try". Empty when no
984    /// flow context available at dump time.
985    #[serde(default)]
986    pub recent_flows: Vec<FlowAttemptRecord>,
987}
988
989/// v1.0.4 §D7 — Session state exposed to SDK consumers via the
990/// `X-Sim-Health` response header on every runner response.
991///
992/// Additive to v1.0.3 sessions — consumers that ignore the header get
993/// v1.0.3 behavior; consumers that read it get `Degraded` / `Dead` /
994/// `Cycling` transitions without polling.
995#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
996#[non_exhaustive]
997pub enum SimHealthWireState {
998    /// All watched signals inside envelope.
999    #[serde(rename = "healthy")]
1000    Healthy,
1001    /// At least one signal degraded (screenshot p95 slow, /health
1002    /// stale, etc.).
1003    #[serde(rename = "degraded")]
1004    Degraded,
1005    /// Runner is mid-cycle (supervisor auto-restart in progress).
1006    /// Callers should back off until the next `Healthy` transition.
1007    #[serde(rename = "cycling")]
1008    Cycling,
1009    /// Runner or a watched subprocess (SimRenderServer / xcodebuild)
1010    /// is gone. Callers should bail.
1011    #[serde(rename = "dead")]
1012    Dead,
1013}
1014
1015impl SimHealthWireState {
1016    /// Parse a case-insensitive wire string; returns `None` on
1017    /// unknown values so unrecognized future states don't panic.
1018    pub fn from_header(value: &str) -> Option<Self> {
1019        match value.trim().to_ascii_lowercase().as_str() {
1020            "healthy" => Some(Self::Healthy),
1021            "degraded" => Some(Self::Degraded),
1022            "cycling" => Some(Self::Cycling),
1023            "dead" => Some(Self::Dead),
1024            _ => None,
1025        }
1026    }
1027
1028    /// Header string emitted on the runner side.
1029    pub fn as_header(&self) -> &'static str {
1030        match self {
1031            Self::Healthy => "healthy",
1032            Self::Degraded => "degraded",
1033            Self::Cycling => "cycling",
1034            Self::Dead => "dead",
1035        }
1036    }
1037}