Skip to main content

allwright_surface_mobile/
lib.rs

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}