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