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