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