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    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}