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