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 XcuiTest,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum MobileAppKind {
18 Native,
19 Hybrid,
20 BrowserWrapped,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum RuntimeMaturity {
25 Planned,
26 Scaffolding,
27 RuntimeReady,
28 Installable,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct MobileCapabilitySet {
33 pub supports_native_views: bool,
34 pub supports_webviews: bool,
35 pub supports_deep_links: bool,
36 pub supports_shell_commands: bool,
37 pub supports_device_logs: bool,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub struct MobileSurfaceProfile {
42 pub plugin_id: &'static str,
43 pub display_name: &'static str,
44 pub family: SurfaceFamily,
45 pub backends: &'static [MobileAutomationBackend],
46 pub default_backend: MobileAutomationBackend,
47 pub supported_app_kinds: &'static [MobileAppKind],
48 pub capabilities: MobileCapabilitySet,
49 pub bootstrap_hint: &'static str,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub struct MobileRuntimeReadiness {
54 pub maturity: RuntimeMaturity,
55 pub missing_runtime_artifacts: &'static [&'static str],
56 pub next_milestones: &'static [&'static str],
57}
58
59#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "snake_case")]
61pub enum MobilePlatform {
62 Android,
63 Ios,
64}
65
66#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
67#[serde(rename_all = "snake_case")]
68pub enum DeviceConnectionKind {
69 Usb,
70 Emulator,
71 RemoteAdb,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub struct DeviceTarget {
76 pub platform: MobilePlatform,
77 pub device_id: String,
78 pub connection_kind: DeviceConnectionKind,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub struct ConnectOptions {
83 pub platform: MobilePlatform,
84 pub device: Option<String>,
85 pub adb_endpoint: Option<String>,
86 #[serde(default)]
87 pub agent_endpoint: Option<String>,
88 pub preserve_app_state: bool,
89 pub timeout_ms: Option<u32>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
93pub struct LaunchOptions {
94 pub apk_path: Option<String>,
95 pub app_id: Option<String>,
96 pub launch_activity: Option<String>,
97 pub stop_before_launch: bool,
98 pub timeout_ms: Option<u32>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102pub struct MobileAutomationSessionInfo {
103 pub backend: String,
104 pub session_id: String,
105 pub note: String,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct MobileBrowserSessionHandle {
110 pub platform: MobilePlatform,
111 pub automation: MobileAutomationSessionInfo,
112 pub device: DeviceTarget,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116pub struct MobilePageSessionHandle {
117 pub page_id: String,
118 pub package_name: Option<String>,
119 pub activity_name: Option<String>,
120 pub webview_context: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
124pub struct MobilePageInfo {
125 pub note: String,
126 pub page_session: MobilePageSessionHandle,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
130pub struct MobileConnectInfo {
131 pub browser: String,
132 pub note: String,
133 pub browser_session: MobileBrowserSessionHandle,
134 pub initial_page: MobilePageInfo,
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
138#[serde(rename_all = "snake_case")]
139pub enum SelectorFlavor {
140 Css,
141 XPath,
142 UiAutomator,
143 Semantic,
144}
145
146impl SelectorFlavor {
147 fn as_str(self) -> &'static str {
148 match self {
149 Self::Css => "css",
150 Self::XPath => "xpath",
151 Self::UiAutomator => "uia",
152 Self::Semantic => "aw",
153 }
154 }
155}
156
157const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
158 "text",
159 "textcontains",
160 "textmatches",
161 "textstartswith",
162 "classname",
163 "classnamematches",
164 "description",
165 "desc",
166 "descriptioncontains",
167 "desccontains",
168 "descriptionmatches",
169 "descmatches",
170 "descriptionstartswith",
171 "descstartswith",
172 "checkable",
173 "checked",
174 "clickable",
175 "longclickable",
176 "scrollable",
177 "enabled",
178 "focusable",
179 "focused",
180 "selected",
181 "packagename",
182 "package",
183 "packagenamematches",
184 "resourceid",
185 "resourceidmatches",
186 "index",
187 "instance",
188];
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct MobileLocator {
192 pub selector: String,
193}
194
195impl MobileLocator {
196 pub fn normalize(selector: &str) -> Self {
197 Self {
198 selector: normalize_selector_for_transport(selector),
199 }
200 }
201
202 pub fn chain(&self, child_selector: &str) -> Self {
203 Self {
204 selector: chain_selector_for_transport(&self.selector, child_selector),
205 }
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
210pub struct MobileClickInfo {
211 pub selector: String,
212 pub note: String,
213 pub session_id: String,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct MobileElementCountInfo {
218 pub selector: String,
219 pub count: u32,
220 pub note: String,
221}
222
223#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
224pub struct MobileFillInfo {
225 pub selector: String,
226 pub value: String,
227 pub note: String,
228}
229
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231pub struct MobileElementInfo {
232 pub selector: String,
233 pub note: String,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
237pub struct MobilePressInfo {
238 pub selector: String,
239 pub key: String,
240 pub note: String,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
244pub struct MobileTextInfo {
245 pub selector: String,
246 pub text: String,
247 pub note: String,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
251pub struct MobileWaitForSelectorInfo {
252 pub selector: String,
253 pub visible: bool,
254 pub note: String,
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258pub struct MobileScreenshotInfo {
259 pub png_data: Vec<u8>,
260 pub note: String,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct MobileNavigationInfo {
265 pub url: String,
266 pub note: String,
267}
268
269#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
270pub enum MobileHookType {
271 FileChooser,
272 Download,
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276pub struct MobileHookRegistration {
277 pub opaque_state: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(tag = "hook_type", content = "value", rename_all = "snake_case")]
282pub enum MobileHookResult {
283 FileChooser(MobileFileChooserInfo),
284 Download(MobileDownloadInfo),
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
288pub struct MobileFileChooserInfo {
289 pub file_chooser_id: String,
290 pub is_multiple: bool,
291 pub note: String,
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
295pub struct MobileFileChooserFilesSetInfo {
296 pub file_chooser_id: String,
297 pub files: Vec<String>,
298 pub note: String,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
302pub struct MobileDownloadInfo {
303 pub download_id: String,
304 pub suggested_filename: String,
305 pub note: String,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
309pub struct MobileDownloadSavedInfo {
310 pub download_id: String,
311 pub path: String,
312 pub suggested_filename: String,
313 pub size: u64,
314 pub note: String,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
318#[serde(tag = "command", rename_all = "snake_case")]
319pub enum MobileCommand {
320 RegisterHook {
321 browser_session: MobileBrowserSessionHandle,
322 page_session: MobilePageSessionHandle,
323 hook_type: MobileHookType,
324 },
325 PollHook {
326 browser_session: MobileBrowserSessionHandle,
327 registration: MobileHookRegistration,
328 },
329 SetFileChooserFiles {
330 browser_session: MobileBrowserSessionHandle,
331 page_session: MobilePageSessionHandle,
332 file_chooser_id: String,
333 files: Vec<String>,
334 },
335 SaveDownload {
336 browser_session: MobileBrowserSessionHandle,
337 page_session: MobilePageSessionHandle,
338 download_id: String,
339 path: String,
340 },
341 AccessibilitySnapshot {
342 browser_session: MobileBrowserSessionHandle,
343 page_session: MobilePageSessionHandle,
344 format: String,
345 mode: String,
346 },
347 Connect(ConnectOptions),
348 LaunchApp {
349 browser_session: MobileBrowserSessionHandle,
350 options: LaunchOptions,
351 },
352 OpenPage {
353 browser_session: MobileBrowserSessionHandle,
354 },
355 ClosePage {
356 browser_session: MobileBrowserSessionHandle,
357 page_session: MobilePageSessionHandle,
358 },
359 NavigateApp {
360 browser_session: MobileBrowserSessionHandle,
361 page_session: MobilePageSessionHandle,
362 url: String,
363 timeout_ms: Option<u32>,
364 },
365 ClickElement {
366 browser_session: MobileBrowserSessionHandle,
367 page_session: MobilePageSessionHandle,
368 selector: String,
369 timeout_ms: Option<u32>,
370 },
371 CountElements {
372 browser_session: MobileBrowserSessionHandle,
373 page_session: MobilePageSessionHandle,
374 selector: String,
375 timeout_ms: Option<u32>,
376 },
377 FocusElement {
378 browser_session: MobileBrowserSessionHandle,
379 page_session: MobilePageSessionHandle,
380 selector: String,
381 timeout_ms: Option<u32>,
382 },
383 FillElement {
384 browser_session: MobileBrowserSessionHandle,
385 page_session: MobilePageSessionHandle,
386 selector: String,
387 value: String,
388 timeout_ms: Option<u32>,
389 },
390 PressKey {
391 browser_session: MobileBrowserSessionHandle,
392 page_session: MobilePageSessionHandle,
393 selector: String,
394 key: String,
395 text: Option<String>,
396 timeout_ms: Option<u32>,
397 },
398 GetText {
399 browser_session: MobileBrowserSessionHandle,
400 page_session: MobilePageSessionHandle,
401 selector: String,
402 timeout_ms: Option<u32>,
403 },
404 GetInnerText {
405 browser_session: MobileBrowserSessionHandle,
406 page_session: MobilePageSessionHandle,
407 selector: String,
408 timeout_ms: Option<u32>,
409 },
410 WaitForSelector {
411 browser_session: MobileBrowserSessionHandle,
412 page_session: MobilePageSessionHandle,
413 selector: String,
414 visible: bool,
415 timeout_ms: Option<u32>,
416 },
417 Screenshot {
418 browser_session: MobileBrowserSessionHandle,
419 page_session: MobilePageSessionHandle,
420 timeout_ms: Option<u32>,
421 full_page: bool,
422 },
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
426#[serde(tag = "result", rename_all = "snake_case")]
427pub enum MobileCommandResult {
428 RegisterHook(MobileHookRegistration),
429 PollHook(MobileHookResult),
430 SetFileChooserFiles(MobileFileChooserFilesSetInfo),
431 SaveDownload(MobileDownloadSavedInfo),
432 AccessibilitySnapshot(allwright_plugin_sdk::AccessibilitySnapshotInfo),
433 Connect(MobileConnectInfo),
434 LaunchApp(MobilePageInfo),
435 OpenPage(MobilePageInfo),
436 ClosePage,
437 NavigateApp(MobileNavigationInfo),
438 ClickElement(MobileClickInfo),
439 CountElements(MobileElementCountInfo),
440 FocusElement(MobileElementInfo),
441 FillElement(MobileFillInfo),
442 PressKey(MobilePressInfo),
443 GetText(MobileTextInfo),
444 GetInnerText(MobileTextInfo),
445 WaitForSelector(MobileWaitForSelectorInfo),
446 Screenshot(MobileScreenshotInfo),
447}
448
449pub fn shared_descriptor() -> SurfacePluginDescriptor {
450 SurfacePluginDescriptor {
451 id: SURFACE_ID,
452 family: SurfaceFamily::Mobile,
453 version: env!("CARGO_PKG_VERSION"),
454 description: "Shared mobile surface abstractions for Android and iOS plugins.",
455 }
456}
457
458pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
459 sleep(Duration::from_millis(delay_ms)).await;
460 format!("{label} ready")
461}
462
463pub async fn boot() -> String {
464 boot_surface("mobile", 25).await
465}
466
467fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
468 let lowered = selector.to_ascii_lowercase();
469 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
470 return Some((SelectorFlavor::XPath, 6));
471 }
472 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
473 return Some((SelectorFlavor::UiAutomator, 4));
474 }
475 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
476 return Some((SelectorFlavor::Semantic, 3));
477 }
478 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
479 return Some((SelectorFlavor::UiAutomator, prefix_len));
480 }
481 if lowered.starts_with("text=") || lowered.starts_with("text:") {
482 return Some((SelectorFlavor::UiAutomator, 5));
483 }
484 if lowered.starts_with("id=") || lowered.starts_with("id:") {
485 return Some((SelectorFlavor::Css, 3));
486 }
487 if lowered.starts_with("css=") || lowered.starts_with("css:") {
488 return Some((SelectorFlavor::Css, 4));
489 }
490 None
491}
492
493fn uiautomator_selector_prefix_len(lowered: &str) -> Option<usize> {
494 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
495 if lowered.starts_with(key) {
496 let separator = lowered.as_bytes().get(key.len()).copied()?;
497 if separator == b'=' || separator == b':' {
498 return Some(key.len() + 1);
499 }
500 }
501 None
502 })
503}
504
505fn find_json_string_end(value: &str) -> Option<usize> {
506 let bytes = value.as_bytes();
507 if bytes.first().copied()? != b'"' {
508 return None;
509 }
510
511 let mut index = 1usize;
512 let mut escaped = false;
513 while index < bytes.len() {
514 let byte = bytes[index];
515 if escaped {
516 escaped = false;
517 index += 1;
518 continue;
519 }
520 match byte {
521 b'\\' => escaped = true,
522 b'"' => return Some(index + 1),
523 _ => {}
524 }
525 index += 1;
526 }
527 None
528}
529
530fn is_normalized_transport_selector(selector: &str) -> bool {
531 let trimmed = selector.trim();
532 if trimmed.is_empty() {
533 return false;
534 }
535
536 let mut index = 0usize;
537 while index < trimmed.len() {
538 let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
539 return false;
540 };
541
542 index += prefix_len;
543 let remainder = &trimmed[index..];
544 if !remainder.starts_with('"') {
545 return false;
546 }
547
548 let Some(json_end) = find_json_string_end(remainder) else {
549 return false;
550 };
551 index += json_end;
552
553 if index == trimmed.len() {
554 return true;
555 }
556
557 let whitespace_len = trimmed[index..]
558 .chars()
559 .take_while(|char| char.is_ascii_whitespace())
560 .count();
561 if whitespace_len == 0 {
562 return false;
563 }
564 index += whitespace_len;
565
566 if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
567 return false;
568 }
569 }
570
571 true
572}
573
574fn decode_selector_body(body: &str) -> String {
575 let candidate = body.trim();
576 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
577 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
578 return unescape_shell_escaped_selector(&decoded);
579 }
580 }
581 unescape_shell_escaped_selector(candidate)
582}
583
584fn unescape_shell_escaped_selector(value: &str) -> String {
585 let mut result = String::with_capacity(value.len());
586 let mut chars = value.chars().peekable();
587 while let Some(ch) = chars.next() {
588 if ch == '\\' {
589 match chars.peek().copied() {
590 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
591 result.push(chars.next().expect("peeked char should exist"));
592 continue;
593 }
594 _ => {}
595 }
596 }
597 result.push(ch);
598 }
599 result
600}
601
602pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
603 let trimmed = selector.trim();
604 let lowered = trimmed.to_ascii_lowercase();
605 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
606 return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
607 }
608 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
609 return (
610 SelectorFlavor::UiAutomator,
611 decode_selector_body(&trimmed[4..]),
612 );
613 }
614 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
615 return (
616 SelectorFlavor::Semantic,
617 decode_selector_body(&trimmed[3..]),
618 );
619 }
620 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
621 return (
622 SelectorFlavor::UiAutomator,
623 trimmed[..prefix_len - 1].to_string()
624 + "="
625 + &decode_selector_body(&trimmed[prefix_len..]),
626 );
627 }
628 if lowered.starts_with("text=") || lowered.starts_with("text:") {
629 let body = decode_selector_body(&trimmed[5..]);
630 return (SelectorFlavor::UiAutomator, format!("text={body}"));
631 }
632 if lowered.starts_with("id=") || lowered.starts_with("id:") {
633 let body = decode_selector_body(&trimmed[3..]);
634 let normalized = if body.starts_with('#') {
635 body
636 } else {
637 format!("#{body}")
638 };
639 return (SelectorFlavor::Css, normalized);
640 }
641 if lowered.starts_with("css=") || lowered.starts_with("css:") {
642 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
643 }
644 if trimmed.starts_with("//")
645 || trimmed.starts_with(".//")
646 || trimmed.starts_with("../")
647 || trimmed.starts_with('/')
648 || trimmed.starts_with('(')
649 {
650 return (SelectorFlavor::XPath, trimmed.to_string());
651 }
652 (SelectorFlavor::Css, trimmed.to_string())
653}
654
655fn normalize_reference_selector(selector: &str) -> Option<String> {
656 let trimmed = selector.trim();
657 let lowered = trimmed.to_ascii_lowercase();
658 (lowered.starts_with("ref=") || lowered.starts_with("ref:"))
659 .then(|| format!("ref={}", decode_selector_body(&trimmed[4..])))
660}
661
662pub fn normalize_selector_for_transport(selector: &str) -> String {
663 let trimmed = selector.trim();
664 if trimmed.is_empty() {
665 return String::new();
666 }
667 if let Some(reference) = normalize_reference_selector(trimmed) {
668 return reference;
669 }
670 if is_normalized_transport_selector(trimmed) {
671 return trimmed.to_string();
672 }
673 let (flavor, body) = parse_selector_for_transport(selector);
674 format!(
675 "{}={}",
676 flavor.as_str(),
677 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
678 )
679}
680
681pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
682 let parent_selector = if parent.trim().is_empty() {
683 String::new()
684 } else {
685 normalize_selector_for_transport(parent)
686 };
687 let child_selector = if child.trim().is_empty() {
688 String::new()
689 } else {
690 normalize_selector_for_transport(child)
691 };
692 if parent_selector.is_empty() {
693 return child_selector;
694 }
695 if child_selector.is_empty() {
696 return parent_selector;
697 }
698 format!("{parent_selector} {child_selector}")
699}
700
701#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[tokio::test]
706 async fn boots_mobile_runtime() {
707 assert_eq!(boot().await, "mobile ready");
708 }
709
710 #[tokio::test]
711 async fn boots_named_mobile_surface() {
712 assert_eq!(boot_surface("android", 1).await, "android ready");
713 }
714
715 #[test]
716 fn normalizes_xpath_and_css_like_web_clients() {
717 assert_eq!(
718 normalize_selector_for_transport("xpath=//android.widget.TextView"),
719 "xpath=\"//android.widget.TextView\""
720 );
721 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
722 assert_eq!(normalize_selector_for_transport("ref=e1-2"), "ref=e1-2");
723 assert_eq!(normalize_selector_for_transport("ref:e1-2"), "ref=e1-2");
724 assert_eq!(normalize_selector_for_transport("ref=\"e1-2\""), "ref=e1-2");
725 assert_eq!(
726 normalize_selector_for_transport("Id=bottom_nav_account"),
727 "css=\"#bottom_nav_account\""
728 );
729 assert_eq!(
730 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
731 "css=\"#bottom_nav_account\""
732 );
733 assert_eq!(
734 normalize_selector_for_transport("text=Account"),
735 "uia=\"text=Account\""
736 );
737 assert_eq!(
738 normalize_selector_for_transport("textContains=Account"),
739 "uia=\"textContains=Account\""
740 );
741 assert_eq!(
742 normalize_selector_for_transport("resourceId=com.example:id/login"),
743 "uia=\"resourceId=com.example:id/login\""
744 );
745 assert_eq!(
746 normalize_selector_for_transport("descriptionContains=Account"),
747 "uia=\"descriptionContains=Account\""
748 );
749 assert_eq!(
750 normalize_selector_for_transport("selected=true"),
751 "uia=\"selected=true\""
752 );
753 assert_eq!(
754 normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
755 "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
756 );
757 }
758
759 #[test]
760 fn chains_mobile_locators_like_web_locators() {
761 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
762 let child = parent.chain("css=.cta");
763 assert_eq!(
764 child.selector,
765 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
766 );
767 }
768
769 #[test]
770 fn mobile_connect_command_returns_web_like_session_shape() {
771 let command = MobileCommand::Connect(ConnectOptions {
772 platform: MobilePlatform::Android,
773 device: Some("emulator-5554".to_string()),
774 adb_endpoint: None,
775 agent_endpoint: None,
776 preserve_app_state: true,
777 timeout_ms: Some(5_000),
778 });
779
780 match command {
781 MobileCommand::Connect(options) => {
782 assert_eq!(options.platform, MobilePlatform::Android);
783 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
784 }
785 _ => panic!("expected connect command"),
786 }
787 }
788
789 #[test]
790 fn mobile_launch_command_keeps_launch_shape_separate() {
791 let browser_session = MobileBrowserSessionHandle {
792 platform: MobilePlatform::Android,
793 automation: MobileAutomationSessionInfo {
794 backend: "uiautomator2".to_string(),
795 session_id: "uiautomator2:emulator-5554".to_string(),
796 note: "ready".to_string(),
797 },
798 device: DeviceTarget {
799 platform: MobilePlatform::Android,
800 device_id: "emulator-5554".to_string(),
801 connection_kind: DeviceConnectionKind::Emulator,
802 },
803 };
804
805 let command = MobileCommand::LaunchApp {
806 browser_session,
807 options: LaunchOptions {
808 apk_path: Some("/tmp/app.apk".to_string()),
809 app_id: Some("dev.allwright.sample".to_string()),
810 launch_activity: Some(".MainActivity".to_string()),
811 stop_before_launch: true,
812 timeout_ms: Some(15_000),
813 },
814 };
815
816 match command {
817 MobileCommand::LaunchApp { options, .. } => {
818 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
819 assert!(options.stop_before_launch);
820 }
821 _ => panic!("expected launch command"),
822 }
823 }
824}