Skip to main content

allwright_surface_mobile/
lib.rs

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