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