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