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