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}