1use allwright_plugin_sdk::SurfaceFamily;
2use allwright_plugin_sdk::SurfacePluginDescriptor;
3use serde::{Deserialize, Serialize};
4use tokio::time::{Duration, sleep};
5
6pub const SURFACE_ID: &str = "mobile";
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum MobileAutomationBackend {
10 UiAutomator2,
11 Espresso,
12 WebViewBridge,
13}
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum MobileAppKind {
17 Native,
18 Hybrid,
19 BrowserWrapped,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RuntimeMaturity {
24 Planned,
25 Scaffolding,
26 RuntimeReady,
27 Installable,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct MobileCapabilitySet {
32 pub supports_native_views: bool,
33 pub supports_webviews: bool,
34 pub supports_deep_links: bool,
35 pub supports_shell_commands: bool,
36 pub supports_device_logs: bool,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct MobileSurfaceProfile {
41 pub plugin_id: &'static str,
42 pub display_name: &'static str,
43 pub family: SurfaceFamily,
44 pub backends: &'static [MobileAutomationBackend],
45 pub default_backend: MobileAutomationBackend,
46 pub supported_app_kinds: &'static [MobileAppKind],
47 pub capabilities: MobileCapabilitySet,
48 pub bootstrap_hint: &'static str,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct MobileRuntimeReadiness {
53 pub maturity: RuntimeMaturity,
54 pub missing_runtime_artifacts: &'static [&'static str],
55 pub next_milestones: &'static [&'static str],
56}
57
58#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
59#[serde(rename_all = "snake_case")]
60pub enum MobilePlatform {
61 Android,
62 Ios,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum DeviceConnectionKind {
68 Usb,
69 Emulator,
70 RemoteAdb,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct DeviceTarget {
75 pub platform: MobilePlatform,
76 pub device_id: String,
77 pub connection_kind: DeviceConnectionKind,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81pub struct ConnectOptions {
82 pub platform: MobilePlatform,
83 pub device: Option<String>,
84 pub adb_endpoint: Option<String>,
85 pub preserve_app_state: bool,
86 pub timeout_ms: Option<u32>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct LaunchOptions {
91 pub apk_path: Option<String>,
92 pub app_id: Option<String>,
93 pub launch_activity: Option<String>,
94 pub stop_before_launch: bool,
95 pub timeout_ms: Option<u32>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct MobileAutomationSessionInfo {
100 pub backend: String,
101 pub session_id: String,
102 pub note: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
106pub struct MobileBrowserSessionHandle {
107 pub platform: MobilePlatform,
108 pub automation: MobileAutomationSessionInfo,
109 pub device: DeviceTarget,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
113pub struct MobilePageSessionHandle {
114 pub page_id: String,
115 pub package_name: Option<String>,
116 pub activity_name: Option<String>,
117 pub webview_context: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121pub struct MobilePageInfo {
122 pub note: String,
123 pub page_session: MobilePageSessionHandle,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
127pub struct MobileConnectInfo {
128 pub browser: String,
129 pub note: String,
130 pub browser_session: MobileBrowserSessionHandle,
131 pub initial_page: MobilePageInfo,
132}
133
134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "snake_case")]
136pub enum SelectorFlavor {
137 Css,
138 XPath,
139}
140
141impl SelectorFlavor {
142 fn as_str(self) -> &'static str {
143 match self {
144 Self::Css => "css",
145 Self::XPath => "xpath",
146 }
147 }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub struct MobileLocator {
152 pub selector: String,
153}
154
155impl MobileLocator {
156 pub fn normalize(selector: &str) -> Self {
157 Self {
158 selector: normalize_selector_for_transport(selector),
159 }
160 }
161
162 pub fn chain(&self, child_selector: &str) -> Self {
163 Self {
164 selector: chain_selector_for_transport(&self.selector, child_selector),
165 }
166 }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170pub struct MobileClickInfo {
171 pub selector: String,
172 pub note: String,
173 pub session_id: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177pub struct MobileElementCountInfo {
178 pub selector: String,
179 pub count: u32,
180 pub note: String,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184pub struct MobileFillInfo {
185 pub selector: String,
186 pub value: String,
187 pub note: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct MobileTextInfo {
192 pub selector: String,
193 pub text: String,
194 pub note: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198pub struct MobileWaitForSelectorInfo {
199 pub selector: String,
200 pub visible: bool,
201 pub note: String,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(tag = "command", rename_all = "snake_case")]
206pub enum MobileCommand {
207 Connect(ConnectOptions),
208 LaunchApp {
209 browser_session: MobileBrowserSessionHandle,
210 options: LaunchOptions,
211 },
212 OpenPage {
213 browser_session: MobileBrowserSessionHandle,
214 },
215 ClosePage {
216 browser_session: MobileBrowserSessionHandle,
217 page_session: MobilePageSessionHandle,
218 },
219 ClickElement {
220 browser_session: MobileBrowserSessionHandle,
221 page_session: MobilePageSessionHandle,
222 selector: String,
223 timeout_ms: Option<u32>,
224 },
225 CountElements {
226 browser_session: MobileBrowserSessionHandle,
227 page_session: MobilePageSessionHandle,
228 selector: String,
229 timeout_ms: Option<u32>,
230 },
231 FillElement {
232 browser_session: MobileBrowserSessionHandle,
233 page_session: MobilePageSessionHandle,
234 selector: String,
235 value: String,
236 timeout_ms: Option<u32>,
237 },
238 GetText {
239 browser_session: MobileBrowserSessionHandle,
240 page_session: MobilePageSessionHandle,
241 selector: String,
242 timeout_ms: Option<u32>,
243 },
244 WaitForSelector {
245 browser_session: MobileBrowserSessionHandle,
246 page_session: MobilePageSessionHandle,
247 selector: String,
248 visible: bool,
249 timeout_ms: Option<u32>,
250 },
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(tag = "result", rename_all = "snake_case")]
255pub enum MobileCommandResult {
256 Connect(MobileConnectInfo),
257 LaunchApp(MobilePageInfo),
258 OpenPage(MobilePageInfo),
259 ClosePage,
260 ClickElement(MobileClickInfo),
261 CountElements(MobileElementCountInfo),
262 FillElement(MobileFillInfo),
263 GetText(MobileTextInfo),
264 WaitForSelector(MobileWaitForSelectorInfo),
265}
266
267pub fn shared_descriptor() -> SurfacePluginDescriptor {
268 SurfacePluginDescriptor {
269 id: SURFACE_ID,
270 family: SurfaceFamily::Mobile,
271 version: env!("CARGO_PKG_VERSION"),
272 description: "Shared mobile surface abstractions for Android and iOS plugins.",
273 }
274}
275
276pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
277 sleep(Duration::from_millis(delay_ms)).await;
278 format!("{label} ready")
279}
280
281pub async fn boot() -> String {
282 boot_surface("mobile", 25).await
283}
284
285fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
286 let lowered = selector.to_ascii_lowercase();
287 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
288 return Some((SelectorFlavor::XPath, 6));
289 }
290 if lowered.starts_with("id=") || lowered.starts_with("id:") {
291 return Some((SelectorFlavor::Css, 3));
292 }
293 if lowered.starts_with("css=") || lowered.starts_with("css:") {
294 return Some((SelectorFlavor::Css, 4));
295 }
296 None
297}
298
299fn find_json_string_end(value: &str) -> Option<usize> {
300 let bytes = value.as_bytes();
301 if bytes.first().copied()? != b'"' {
302 return None;
303 }
304
305 let mut index = 1usize;
306 let mut escaped = false;
307 while index < bytes.len() {
308 let byte = bytes[index];
309 if escaped {
310 escaped = false;
311 index += 1;
312 continue;
313 }
314 match byte {
315 b'\\' => escaped = true,
316 b'"' => return Some(index + 1),
317 _ => {}
318 }
319 index += 1;
320 }
321 None
322}
323
324fn is_normalized_transport_selector(selector: &str) -> bool {
325 let trimmed = selector.trim();
326 if trimmed.is_empty() {
327 return false;
328 }
329
330 let mut index = 0usize;
331 while index < trimmed.len() {
332 let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
333 return false;
334 };
335
336 index += prefix_len;
337 let remainder = &trimmed[index..];
338 if !remainder.starts_with('"') {
339 return false;
340 }
341
342 let Some(json_end) = find_json_string_end(remainder) else {
343 return false;
344 };
345 index += json_end;
346
347 if index == trimmed.len() {
348 return true;
349 }
350
351 let whitespace_len = trimmed[index..]
352 .chars()
353 .take_while(|char| char.is_ascii_whitespace())
354 .count();
355 if whitespace_len == 0 {
356 return false;
357 }
358 index += whitespace_len;
359
360 if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
361 return false;
362 }
363 }
364
365 true
366}
367
368fn decode_selector_body(body: &str) -> String {
369 let candidate = body.trim();
370 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
371 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
372 return unescape_shell_escaped_selector(&decoded);
373 }
374 }
375 unescape_shell_escaped_selector(candidate)
376}
377
378fn unescape_shell_escaped_selector(value: &str) -> String {
379 let mut result = String::with_capacity(value.len());
380 let mut chars = value.chars().peekable();
381 while let Some(ch) = chars.next() {
382 if ch == '\\' {
383 match chars.peek().copied() {
384 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
385 result.push(chars.next().expect("peeked char should exist"));
386 continue;
387 }
388 _ => {}
389 }
390 }
391 result.push(ch);
392 }
393 result
394}
395
396pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
397 let trimmed = selector.trim();
398 let lowered = trimmed.to_ascii_lowercase();
399 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
400 return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
401 }
402 if lowered.starts_with("id=") || lowered.starts_with("id:") {
403 let body = decode_selector_body(&trimmed[3..]);
404 let normalized = if body.starts_with('#') {
405 body
406 } else {
407 format!("#{body}")
408 };
409 return (SelectorFlavor::Css, normalized);
410 }
411 if lowered.starts_with("css=") || lowered.starts_with("css:") {
412 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
413 }
414 if trimmed.starts_with("//")
415 || trimmed.starts_with(".//")
416 || trimmed.starts_with("../")
417 || trimmed.starts_with('/')
418 || trimmed.starts_with('(')
419 {
420 return (SelectorFlavor::XPath, trimmed.to_string());
421 }
422 (SelectorFlavor::Css, trimmed.to_string())
423}
424
425pub fn normalize_selector_for_transport(selector: &str) -> String {
426 let trimmed = selector.trim();
427 if trimmed.is_empty() {
428 return String::new();
429 }
430 if is_normalized_transport_selector(trimmed) {
431 return trimmed.to_string();
432 }
433 let (flavor, body) = parse_selector_for_transport(selector);
434 format!(
435 "{}={}",
436 flavor.as_str(),
437 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
438 )
439}
440
441pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
442 let parent_selector = if parent.trim().is_empty() {
443 String::new()
444 } else {
445 normalize_selector_for_transport(parent)
446 };
447 let child_selector = if child.trim().is_empty() {
448 String::new()
449 } else {
450 normalize_selector_for_transport(child)
451 };
452 if parent_selector.is_empty() {
453 return child_selector;
454 }
455 if child_selector.is_empty() {
456 return parent_selector;
457 }
458 format!("{parent_selector} {child_selector}")
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 #[tokio::test]
466 async fn boots_mobile_runtime() {
467 assert_eq!(boot().await, "mobile ready");
468 }
469
470 #[tokio::test]
471 async fn boots_named_mobile_surface() {
472 assert_eq!(boot_surface("android", 1).await, "android ready");
473 }
474
475 #[test]
476 fn normalizes_xpath_and_css_like_web_clients() {
477 assert_eq!(
478 normalize_selector_for_transport("xpath=//android.widget.TextView"),
479 "xpath=\"//android.widget.TextView\""
480 );
481 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
482 assert_eq!(
483 normalize_selector_for_transport("Id=bottom_nav_account"),
484 "css=\"#bottom_nav_account\""
485 );
486 assert_eq!(
487 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
488 "css=\"#bottom_nav_account\""
489 );
490 }
491
492 #[test]
493 fn chains_mobile_locators_like_web_locators() {
494 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
495 let child = parent.chain("css=.cta");
496 assert_eq!(
497 child.selector,
498 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
499 );
500 }
501
502 #[test]
503 fn mobile_connect_command_returns_web_like_session_shape() {
504 let command = MobileCommand::Connect(ConnectOptions {
505 platform: MobilePlatform::Android,
506 device: Some("emulator-5554".to_string()),
507 adb_endpoint: None,
508 preserve_app_state: true,
509 timeout_ms: Some(5_000),
510 });
511
512 match command {
513 MobileCommand::Connect(options) => {
514 assert_eq!(options.platform, MobilePlatform::Android);
515 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
516 }
517 _ => panic!("expected connect command"),
518 }
519 }
520
521 #[test]
522 fn mobile_launch_command_keeps_launch_shape_separate() {
523 let browser_session = MobileBrowserSessionHandle {
524 platform: MobilePlatform::Android,
525 automation: MobileAutomationSessionInfo {
526 backend: "uiautomator2".to_string(),
527 session_id: "uiautomator2:emulator-5554".to_string(),
528 note: "ready".to_string(),
529 },
530 device: DeviceTarget {
531 platform: MobilePlatform::Android,
532 device_id: "emulator-5554".to_string(),
533 connection_kind: DeviceConnectionKind::Emulator,
534 },
535 };
536
537 let command = MobileCommand::LaunchApp {
538 browser_session,
539 options: LaunchOptions {
540 apk_path: Some("/tmp/app.apk".to_string()),
541 app_id: Some("dev.allwright.sample".to_string()),
542 launch_activity: Some(".MainActivity".to_string()),
543 stop_before_launch: true,
544 timeout_ms: Some(15_000),
545 },
546 };
547
548 match command {
549 MobileCommand::LaunchApp { options, .. } => {
550 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
551 assert!(options.stop_before_launch);
552 }
553 _ => panic!("expected launch command"),
554 }
555 }
556}