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