smix_driver/traits.rs
1//! `Driver` trait: cross-platform sense + act abstraction.
2//!
3//! Two-trait architecture:
4//!
5//! - `Driver` (this trait) — sense + act, backed by an HTTP runner
6//! client (XCUITest server for iOS, ktor/UIAutomator2 for Android)
7//! - `DeviceControl` (in smix-sdk) — sim/host control, backed by
8//! simctl (iOS) / adb (Android)
9//!
10//! Methods NOT on this trait (App-layer aggregation or platform-specific
11//! getter):
12//! - `App::describe()` — calls `Driver::tree()` + `collect_visible_summaries()`
13//! - `IosDriver::runner()` — typed HttpRunnerClient getter, iOS-internal
14//! (Android uses a different runner client type)
15//! - `IosDriver::dispose()` — runner cleanup, internal
16//! - `App::screenshot()` — calls `DeviceControl::screenshot()` (simctl-backed)
17//! - `App::mark_fixture_action()` — pure ledger record, no platform dispatch
18
19use async_trait::async_trait;
20use std::time::Duration;
21
22use smix_error::ExpectationFailure;
23use smix_input::{KeyName, SwipeDirection};
24use smix_runner_client::{IncludeScope, OcrFrame, SystemPopup, TapMode};
25use smix_screen::A11yNode;
26use smix_selector::Selector;
27
28use crate::Orientation;
29
30/// Platform identifier for cross-platform dispatch.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum Platform {
33 Ios,
34 Android,
35}
36
37/// Sense + act capabilities for a single device. Two-trait architecture
38/// pair with `smix_sdk::DeviceControl` (sim/host control in smix-sdk).
39///
40/// Signatures intentionally match iOS impl (`IosDriver` inherent methods)
41/// 1:1 so the trait impl is a pure delegation layer.
42#[async_trait]
43pub trait Driver: Send + Sync {
44 /// Platform identifier — `Platform::Ios` or `Platform::Android`.
45 fn platform(&self) -> Platform;
46
47 /// iOS-only escape hatch: access `IosDriver`'s inherent
48 /// `runner()` for capsule recording (start_record / stop_record) +
49 /// `describe()` aggregation. Android impl returns `None`. Default
50 /// `None` so non-iOS impls don't need to implement.
51 fn as_ios_driver(&self) -> Option<&crate::IosDriver> {
52 None
53 }
54
55 /// Set the target bundle id sent to the runner as the
56 /// `App-Bundle-Id` header on every request. iOS rebinds
57 /// `XCUIApplication(bundleIdentifier:)` per request with it;
58 /// Android spells `<pkg>:id/<tag>` resource ids with it. Both
59 /// override; the default no-op is for impls that talk to neither.
60 fn set_target_bundle_id(&mut self, _bundle: &str) {}
61
62 /// Enable `App-Activate: true` header on every request so the iOS
63 /// runner calls `.activate()` on the resolved target before
64 /// operating.
65 ///
66 /// iOS-only by design, not by omission: Android foregrounds with an
67 /// `am start` shell command, which is a once-per-session action
68 /// rather than something to repeat on every request.
69 fn set_auto_activate(&mut self, _activate: bool) {}
70
71 /// Force key-event dispatch for text input, bypassing a11y-focus
72 /// resolution. Wires as the `Input-Dispatch-Mode: key-events`
73 /// header. Default no-op.
74 fn set_force_key_events(&mut self, _force: bool) {}
75
76 /// Attach a `Session-Id` header to every subsequent
77 /// request. Set to `Some(id)` after `POST /session/open`; set to
78 /// `None` to revert to the legacy per-request rebind path (which
79 /// is rate-limited to at most one `.activate()` per 5 s per
80 /// bundle-id). Default no-op — only impls backed by
81 /// an HTTP runner override.
82 fn set_session_id(&mut self, _id: Option<String>) {}
83
84 // === Sense (9) ============================================================
85
86 /// Fetch full a11y tree (`GET /tree`).
87 async fn tree(&self, include: Option<IncludeScope>) -> Result<A11yNode, ExpectationFailure>;
88
89 /// Boolean existence quick-probe.
90 async fn find(
91 &self,
92 selector: &Selector,
93 include: Option<IncludeScope>,
94 ) -> Result<bool, ExpectationFailure>;
95
96 /// Resolve selector → single matching node.
97 async fn find_one(
98 &self,
99 selector: &Selector,
100 include: Option<IncludeScope>,
101 ) -> Result<Option<A11yNode>, ExpectationFailure>;
102
103 /// Resolve selector → all matching nodes.
104 async fn find_all(
105 &self,
106 selector: &Selector,
107 include: Option<IncludeScope>,
108 ) -> Result<Vec<A11yNode>, ExpectationFailure>;
109
110 /// Resolve selector → centroid as viewport-normalized `(nx, ny)`.
111 async fn find_norm_coord(
112 &self,
113 selector: &Selector,
114 ) -> Result<Option<(f64, f64)>, ExpectationFailure>;
115
116 /// Apple Vision / ML Kit OCR find text in current screenshot.
117 /// `recognition_level` is `"accurate"` or `"fast"`.
118 async fn find_text_by_ocr(
119 &self,
120 text: &str,
121 locales: &[String],
122 recognition_level: &str,
123 ) -> Result<Option<OcrFrame>, ExpectationFailure>;
124
125 /// List visible system-level popups (alerts, permission dialogs).
126 async fn system_popups(
127 &self,
128 include: Option<IncludeScope>,
129 ) -> Result<Vec<SystemPopup>, ExpectationFailure>;
130
131 /// Tap a button on a system popup by id.
132 async fn system_popup_action(
133 &self,
134 popup_id: &str,
135 button_id: &str,
136 ) -> Result<bool, ExpectationFailure>;
137
138 /// Wait until selector resolves (returns matched node when ready).
139 async fn wait_for(
140 &self,
141 selector: &Selector,
142 timeout: Duration,
143 include: Option<IncludeScope>,
144 ) -> Result<A11yNode, ExpectationFailure>;
145
146 // === Act (17) ============================================================
147
148 /// Tap an element (default mode = element-anchored coord tap).
149 /// Tap a selector `times` times, spaced on the event timeline.
150 ///
151 /// Default is one resolve per touch through [`Self::tap`], which is
152 /// what every platform can already do. A runner that can pack the
153 /// touches into one synthesise overrides this and gets an interval
154 /// the caller states rather than one the round trip decides.
155 async fn tap_burst(
156 &self,
157 selector: &Selector,
158 times: u32,
159 _interval_ms: Option<u32>,
160 _hold_ms: Option<u32>,
161 include: Option<IncludeScope>,
162 ) -> Result<(), ExpectationFailure> {
163 for _ in 0..times.max(1) {
164 self.tap(selector, include).await?;
165 }
166 Ok(())
167 }
168
169 /// Tap a selector, and report what the touch landed on.
170 ///
171 /// A platform that cannot answer says so with
172 /// `ActOutcome::unjudged()` rather than with a bare success. "I
173 /// could not tell" is a different fact from "it landed", and only
174 /// one of them is what a caller reads out of a tap that returned
175 /// without error.
176 async fn tap(
177 &self,
178 selector: &Selector,
179 include: Option<IncludeScope>,
180 ) -> Result<crate::ActOutcome, ExpectationFailure>;
181
182 /// Tap with explicit mode (Path A vs Path B).
183 async fn tap_with_mode(
184 &self,
185 selector: &Selector,
186 mode: TapMode,
187 include: Option<IncludeScope>,
188 ) -> Result<(), ExpectationFailure>;
189
190 /// Tap at viewport-normalized coordinate (escape hatch).
191 async fn tap_at_norm_coord(&self, nx: f64, ny: f64) -> Result<(), ExpectationFailure>;
192
193 /// Tap by accessibility identifier via the swift `/tap-by-id`
194 /// route (IOHID synthesize at element-frame center).
195 async fn tap_by_id(&self, id: &str) -> Result<(), ExpectationFailure>;
196
197 /// Double-tap a selector.
198 async fn double_tap(
199 &self,
200 selector: &Selector,
201 include: Option<IncludeScope>,
202 ) -> Result<(), ExpectationFailure>;
203
204 /// Long-press a selector for `duration`.
205 ///
206 /// Returns when the touch was held, on this host's clock, so a
207 /// caller capturing frames alongside can tell whether they fall
208 /// inside the press. A platform whose runner cannot report the
209 /// bounds returns [`PressTiming::unplaceable`] — which reads as "I
210 /// cannot tell", not as a press that happened at time zero.
211 async fn long_press(
212 &self,
213 selector: &Selector,
214 duration: Duration,
215 include: Option<IncludeScope>,
216 ) -> Result<crate::PressTiming, ExpectationFailure>;
217
218 /// Fill text into a focused / matched input.
219 async fn fill(
220 &self,
221 selector: &Selector,
222 text: &str,
223 include: Option<IncludeScope>,
224 ) -> Result<(), ExpectationFailure>;
225
226 /// Clear text from a focused / matched input.
227 async fn clear(
228 &self,
229 selector: &Selector,
230 include: Option<IncludeScope>,
231 ) -> Result<(), ExpectationFailure>;
232
233 /// Press a named key (Return / Tab / Backspace / etc).
234 async fn press_key(&self, key: KeyName) -> Result<(), ExpectationFailure>;
235
236 /// Scroll until a selector is visible.
237 async fn scroll(
238 &self,
239 selector: &Selector,
240 direction: SwipeDirection,
241 ) -> Result<(), ExpectationFailure>;
242
243 /// One-shot swipe in a direction (no probe loop).
244 async fn swipe_once(&self, direction: SwipeDirection) -> Result<(), ExpectationFailure>;
245
246 /// Swipe between two viewport-normalized coords.
247 async fn swipe_at_norm_coord(
248 &self,
249 from: (f64, f64),
250 to: (f64, f64),
251 ) -> Result<(), ExpectationFailure>;
252
253 /// Dismiss the keyboard.
254 async fn hide_keyboard(&self) -> Result<(), ExpectationFailure>;
255
256 /// Trigger "back" gesture (iOS edge-swipe / Android KEYCODE_BACK).
257 async fn back(&self) -> Result<(), ExpectationFailure>;
258
259 /// Rotate the device.
260 async fn set_orientation(&self, orientation: Orientation) -> Result<(), ExpectationFailure>;
261
262 /// Bring app to foreground.
263 async fn foreground(&self, bundle_id: &str) -> Result<(), ExpectationFailure>;
264
265 /// Evaluate JavaScript in a WebView (iOS: fixture bridge :28080;
266 /// Android: Chrome DevTools Protocol via adb forward).
267 async fn webview_eval(&self, js: &str) -> Result<serde_json::Value, ExpectationFailure>;
268}