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 Capture {
366 browser_session: MobileBrowserSessionHandle,
367 page_session: MobilePageSessionHandle,
368 kind: String,
369 selector: String,
370 attribute_name: String,
371 timeout_ms: Option<u32>,
372 },
373 ClickElement {
374 browser_session: MobileBrowserSessionHandle,
375 page_session: MobilePageSessionHandle,
376 selector: String,
377 timeout_ms: Option<u32>,
378 },
379 CountElements {
380 browser_session: MobileBrowserSessionHandle,
381 page_session: MobilePageSessionHandle,
382 selector: String,
383 timeout_ms: Option<u32>,
384 },
385 FocusElement {
386 browser_session: MobileBrowserSessionHandle,
387 page_session: MobilePageSessionHandle,
388 selector: String,
389 timeout_ms: Option<u32>,
390 },
391 FillElement {
392 browser_session: MobileBrowserSessionHandle,
393 page_session: MobilePageSessionHandle,
394 selector: String,
395 value: String,
396 timeout_ms: Option<u32>,
397 },
398 PressKey {
399 browser_session: MobileBrowserSessionHandle,
400 page_session: MobilePageSessionHandle,
401 selector: String,
402 key: String,
403 text: Option<String>,
404 timeout_ms: Option<u32>,
405 },
406 GetText {
407 browser_session: MobileBrowserSessionHandle,
408 page_session: MobilePageSessionHandle,
409 selector: String,
410 timeout_ms: Option<u32>,
411 },
412 GetInnerText {
413 browser_session: MobileBrowserSessionHandle,
414 page_session: MobilePageSessionHandle,
415 selector: String,
416 timeout_ms: Option<u32>,
417 },
418 WaitForSelector {
419 browser_session: MobileBrowserSessionHandle,
420 page_session: MobilePageSessionHandle,
421 selector: String,
422 visible: bool,
423 timeout_ms: Option<u32>,
424 },
425 Screenshot {
426 browser_session: MobileBrowserSessionHandle,
427 page_session: MobilePageSessionHandle,
428 timeout_ms: Option<u32>,
429 full_page: bool,
430 },
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434#[serde(tag = "result", rename_all = "snake_case")]
435pub enum MobileCommandResult {
436 RegisterHook(MobileHookRegistration),
437 PollHook(MobileHookResult),
438 SetFileChooserFiles(MobileFileChooserFilesSetInfo),
439 SaveDownload(MobileDownloadSavedInfo),
440 AccessibilitySnapshot(allwright_plugin_sdk::AccessibilitySnapshotInfo),
441 Connect(MobileConnectInfo),
442 LaunchApp(MobilePageInfo),
443 OpenPage(MobilePageInfo),
444 ClosePage,
445 NavigateApp(MobileNavigationInfo),
446 Capture(allwright_plugin_sdk::CaptureInfo),
447 ClickElement(MobileClickInfo),
448 CountElements(MobileElementCountInfo),
449 FocusElement(MobileElementInfo),
450 FillElement(MobileFillInfo),
451 PressKey(MobilePressInfo),
452 GetText(MobileTextInfo),
453 GetInnerText(MobileTextInfo),
454 WaitForSelector(MobileWaitForSelectorInfo),
455 Screenshot(MobileScreenshotInfo),
456}
457
458pub fn shared_descriptor() -> SurfacePluginDescriptor {
459 SurfacePluginDescriptor {
460 id: SURFACE_ID,
461 family: SurfaceFamily::Mobile,
462 version: env!("CARGO_PKG_VERSION"),
463 description: "Shared mobile surface abstractions for Android and iOS plugins.",
464 }
465}
466
467pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
468 sleep(Duration::from_millis(delay_ms)).await;
469 format!("{label} ready")
470}
471
472pub async fn boot() -> String {
473 boot_surface("mobile", 25).await
474}
475
476fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
477 let lowered = selector.to_ascii_lowercase();
478 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
479 return Some((SelectorFlavor::XPath, 6));
480 }
481 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
482 return Some((SelectorFlavor::UiAutomator, 4));
483 }
484 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
485 return Some((SelectorFlavor::Semantic, 3));
486 }
487 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
488 return Some((SelectorFlavor::UiAutomator, prefix_len));
489 }
490 if lowered.starts_with("text=") || lowered.starts_with("text:") {
491 return Some((SelectorFlavor::UiAutomator, 5));
492 }
493 if lowered.starts_with("id=") || lowered.starts_with("id:") {
494 return Some((SelectorFlavor::Css, 3));
495 }
496 if lowered.starts_with("css=") || lowered.starts_with("css:") {
497 return Some((SelectorFlavor::Css, 4));
498 }
499 None
500}
501
502fn uiautomator_selector_prefix_len(lowered: &str) -> Option<usize> {
503 UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
504 if lowered.starts_with(key) {
505 let separator = lowered.as_bytes().get(key.len()).copied()?;
506 if separator == b'=' || separator == b':' {
507 return Some(key.len() + 1);
508 }
509 }
510 None
511 })
512}
513
514fn find_json_string_end(value: &str) -> Option<usize> {
515 let bytes = value.as_bytes();
516 if bytes.first().copied()? != b'"' {
517 return None;
518 }
519
520 let mut index = 1usize;
521 let mut escaped = false;
522 while index < bytes.len() {
523 let byte = bytes[index];
524 if escaped {
525 escaped = false;
526 index += 1;
527 continue;
528 }
529 match byte {
530 b'\\' => escaped = true,
531 b'"' => return Some(index + 1),
532 _ => {}
533 }
534 index += 1;
535 }
536 None
537}
538
539fn is_normalized_transport_selector(selector: &str) -> bool {
540 let trimmed = selector.trim();
541 if trimmed.is_empty() {
542 return false;
543 }
544
545 let mut index = 0usize;
546 while index < trimmed.len() {
547 let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
548 return false;
549 };
550
551 index += prefix_len;
552 let remainder = &trimmed[index..];
553 if !remainder.starts_with('"') {
554 return false;
555 }
556
557 let Some(json_end) = find_json_string_end(remainder) else {
558 return false;
559 };
560 index += json_end;
561
562 if index == trimmed.len() {
563 return true;
564 }
565
566 let whitespace_len = trimmed[index..]
567 .chars()
568 .take_while(|char| char.is_ascii_whitespace())
569 .count();
570 if whitespace_len == 0 {
571 return false;
572 }
573 index += whitespace_len;
574
575 if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
576 return false;
577 }
578 }
579
580 true
581}
582
583fn decode_selector_body(body: &str) -> String {
584 let candidate = body.trim();
585 if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
586 if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
587 return unescape_shell_escaped_selector(&decoded);
588 }
589 }
590 unescape_shell_escaped_selector(candidate)
591}
592
593fn unescape_shell_escaped_selector(value: &str) -> String {
594 let mut result = String::with_capacity(value.len());
595 let mut chars = value.chars().peekable();
596 while let Some(ch) = chars.next() {
597 if ch == '\\' {
598 match chars.peek().copied() {
599 Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
600 result.push(chars.next().expect("peeked char should exist"));
601 continue;
602 }
603 _ => {}
604 }
605 }
606 result.push(ch);
607 }
608 result
609}
610
611pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
612 let trimmed = selector.trim();
613 let lowered = trimmed.to_ascii_lowercase();
614 if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
615 return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
616 }
617 if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
618 return (
619 SelectorFlavor::UiAutomator,
620 decode_selector_body(&trimmed[4..]),
621 );
622 }
623 if lowered.starts_with("aw=") || lowered.starts_with("aw:") {
624 return (
625 SelectorFlavor::Semantic,
626 decode_selector_body(&trimmed[3..]),
627 );
628 }
629 if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
630 return (
631 SelectorFlavor::UiAutomator,
632 trimmed[..prefix_len - 1].to_string()
633 + "="
634 + &decode_selector_body(&trimmed[prefix_len..]),
635 );
636 }
637 if lowered.starts_with("text=") || lowered.starts_with("text:") {
638 let body = decode_selector_body(&trimmed[5..]);
639 return (SelectorFlavor::UiAutomator, format!("text={body}"));
640 }
641 if lowered.starts_with("id=") || lowered.starts_with("id:") {
642 let body = decode_selector_body(&trimmed[3..]);
643 let normalized = if body.starts_with('#') {
644 body
645 } else {
646 format!("#{body}")
647 };
648 return (SelectorFlavor::Css, normalized);
649 }
650 if lowered.starts_with("css=") || lowered.starts_with("css:") {
651 return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
652 }
653 if trimmed.starts_with("//")
654 || trimmed.starts_with(".//")
655 || trimmed.starts_with("../")
656 || trimmed.starts_with('/')
657 || trimmed.starts_with('(')
658 {
659 return (SelectorFlavor::XPath, trimmed.to_string());
660 }
661 (SelectorFlavor::Css, trimmed.to_string())
662}
663
664fn normalize_reference_selector(selector: &str) -> Option<String> {
665 let trimmed = selector.trim();
666 let lowered = trimmed.to_ascii_lowercase();
667 (lowered.starts_with("ref=") || lowered.starts_with("ref:"))
668 .then(|| format!("ref={}", decode_selector_body(&trimmed[4..])))
669}
670
671pub fn normalize_selector_for_transport(selector: &str) -> String {
672 let trimmed = selector.trim();
673 if trimmed.is_empty() {
674 return String::new();
675 }
676 if let Some(reference) = normalize_reference_selector(trimmed) {
677 return reference;
678 }
679 if is_normalized_transport_selector(trimmed) {
680 return trimmed.to_string();
681 }
682 let (flavor, body) = parse_selector_for_transport(selector);
683 format!(
684 "{}={}",
685 flavor.as_str(),
686 serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
687 )
688}
689
690pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
691 let parent_selector = if parent.trim().is_empty() {
692 String::new()
693 } else {
694 normalize_selector_for_transport(parent)
695 };
696 let child_selector = if child.trim().is_empty() {
697 String::new()
698 } else {
699 normalize_selector_for_transport(child)
700 };
701 if parent_selector.is_empty() {
702 return child_selector;
703 }
704 if child_selector.is_empty() {
705 return parent_selector;
706 }
707 format!("{parent_selector} {child_selector}")
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[tokio::test]
715 async fn boots_mobile_runtime() {
716 assert_eq!(boot().await, "mobile ready");
717 }
718
719 #[tokio::test]
720 async fn boots_named_mobile_surface() {
721 assert_eq!(boot_surface("android", 1).await, "android ready");
722 }
723
724 #[test]
725 fn normalizes_xpath_and_css_like_web_clients() {
726 assert_eq!(
727 normalize_selector_for_transport("xpath=//android.widget.TextView"),
728 "xpath=\"//android.widget.TextView\""
729 );
730 assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
731 assert_eq!(normalize_selector_for_transport("ref=e1-2"), "ref=e1-2");
732 assert_eq!(normalize_selector_for_transport("ref:e1-2"), "ref=e1-2");
733 assert_eq!(normalize_selector_for_transport("ref=\"e1-2\""), "ref=e1-2");
734 assert_eq!(
735 normalize_selector_for_transport("Id=bottom_nav_account"),
736 "css=\"#bottom_nav_account\""
737 );
738 assert_eq!(
739 normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
740 "css=\"#bottom_nav_account\""
741 );
742 assert_eq!(
743 normalize_selector_for_transport("text=Account"),
744 "uia=\"text=Account\""
745 );
746 assert_eq!(
747 normalize_selector_for_transport("textContains=Account"),
748 "uia=\"textContains=Account\""
749 );
750 assert_eq!(
751 normalize_selector_for_transport("resourceId=com.example:id/login"),
752 "uia=\"resourceId=com.example:id/login\""
753 );
754 assert_eq!(
755 normalize_selector_for_transport("descriptionContains=Account"),
756 "uia=\"descriptionContains=Account\""
757 );
758 assert_eq!(
759 normalize_selector_for_transport("selected=true"),
760 "uia=\"selected=true\""
761 );
762 assert_eq!(
763 normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
764 "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
765 );
766 }
767
768 #[test]
769 fn chains_mobile_locators_like_web_locators() {
770 let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
771 let child = parent.chain("css=.cta");
772 assert_eq!(
773 child.selector,
774 "xpath=\"//android.view.ViewGroup\" css=\".cta\""
775 );
776 }
777
778 #[test]
779 fn mobile_connect_command_returns_web_like_session_shape() {
780 let command = MobileCommand::Connect(ConnectOptions {
781 platform: MobilePlatform::Android,
782 device: Some("emulator-5554".to_string()),
783 adb_endpoint: None,
784 agent_endpoint: None,
785 preserve_app_state: true,
786 timeout_ms: Some(5_000),
787 });
788
789 match command {
790 MobileCommand::Connect(options) => {
791 assert_eq!(options.platform, MobilePlatform::Android);
792 assert_eq!(options.device.as_deref(), Some("emulator-5554"));
793 }
794 _ => panic!("expected connect command"),
795 }
796 }
797
798 #[test]
799 fn mobile_launch_command_keeps_launch_shape_separate() {
800 let browser_session = MobileBrowserSessionHandle {
801 platform: MobilePlatform::Android,
802 automation: MobileAutomationSessionInfo {
803 backend: "uiautomator2".to_string(),
804 session_id: "uiautomator2:emulator-5554".to_string(),
805 note: "ready".to_string(),
806 },
807 device: DeviceTarget {
808 platform: MobilePlatform::Android,
809 device_id: "emulator-5554".to_string(),
810 connection_kind: DeviceConnectionKind::Emulator,
811 },
812 };
813
814 let command = MobileCommand::LaunchApp {
815 browser_session,
816 options: LaunchOptions {
817 apk_path: Some("/tmp/app.apk".to_string()),
818 app_id: Some("dev.allwright.sample".to_string()),
819 launch_activity: Some(".MainActivity".to_string()),
820 stop_before_launch: true,
821 timeout_ms: Some(15_000),
822 },
823 };
824
825 match command {
826 MobileCommand::LaunchApp { options, .. } => {
827 assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
828 assert!(options.stop_before_launch);
829 }
830 _ => panic!("expected launch command"),
831 }
832 }
833}