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}