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