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 MobileTextInfo {
227    pub selector: String,
228    pub text: String,
229    pub note: String,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
233pub struct MobileWaitForSelectorInfo {
234    pub selector: String,
235    pub visible: bool,
236    pub note: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
240#[serde(tag = "command", rename_all = "snake_case")]
241pub enum MobileCommand {
242    Connect(ConnectOptions),
243    LaunchApp {
244        browser_session: MobileBrowserSessionHandle,
245        options: LaunchOptions,
246    },
247    OpenPage {
248        browser_session: MobileBrowserSessionHandle,
249    },
250    ClosePage {
251        browser_session: MobileBrowserSessionHandle,
252        page_session: MobilePageSessionHandle,
253    },
254    ClickElement {
255        browser_session: MobileBrowserSessionHandle,
256        page_session: MobilePageSessionHandle,
257        selector: String,
258        timeout_ms: Option<u32>,
259    },
260    CountElements {
261        browser_session: MobileBrowserSessionHandle,
262        page_session: MobilePageSessionHandle,
263        selector: String,
264        timeout_ms: Option<u32>,
265    },
266    FillElement {
267        browser_session: MobileBrowserSessionHandle,
268        page_session: MobilePageSessionHandle,
269        selector: String,
270        value: String,
271        timeout_ms: Option<u32>,
272    },
273    GetText {
274        browser_session: MobileBrowserSessionHandle,
275        page_session: MobilePageSessionHandle,
276        selector: String,
277        timeout_ms: Option<u32>,
278    },
279    WaitForSelector {
280        browser_session: MobileBrowserSessionHandle,
281        page_session: MobilePageSessionHandle,
282        selector: String,
283        visible: bool,
284        timeout_ms: Option<u32>,
285    },
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(tag = "result", rename_all = "snake_case")]
290pub enum MobileCommandResult {
291    Connect(MobileConnectInfo),
292    LaunchApp(MobilePageInfo),
293    OpenPage(MobilePageInfo),
294    ClosePage,
295    ClickElement(MobileClickInfo),
296    CountElements(MobileElementCountInfo),
297    FillElement(MobileFillInfo),
298    GetText(MobileTextInfo),
299    WaitForSelector(MobileWaitForSelectorInfo),
300}
301
302pub fn shared_descriptor() -> SurfacePluginDescriptor {
303    SurfacePluginDescriptor {
304        id: SURFACE_ID,
305        family: SurfaceFamily::Mobile,
306        version: env!("CARGO_PKG_VERSION"),
307        description: "Shared mobile surface abstractions for Android and iOS plugins.",
308    }
309}
310
311pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
312    sleep(Duration::from_millis(delay_ms)).await;
313    format!("{label} ready")
314}
315
316pub async fn boot() -> String {
317    boot_surface("mobile", 25).await
318}
319
320fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
321    let lowered = selector.to_ascii_lowercase();
322    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
323        return Some((SelectorFlavor::XPath, 6));
324    }
325    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
326        return Some((SelectorFlavor::UiAutomator, 4));
327    }
328    if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
329        return Some((SelectorFlavor::UiAutomator, prefix_len));
330    }
331    if lowered.starts_with("text=") || lowered.starts_with("text:") {
332        return Some((SelectorFlavor::UiAutomator, 5));
333    }
334    if lowered.starts_with("id=") || lowered.starts_with("id:") {
335        return Some((SelectorFlavor::Css, 3));
336    }
337    if lowered.starts_with("css=") || lowered.starts_with("css:") {
338        return Some((SelectorFlavor::Css, 4));
339    }
340    None
341}
342
343fn uiautomator_selector_prefix_len(lowered: &str) -> Option<usize> {
344    UIAUTOMATOR_SELECTOR_KEYS.iter().find_map(|key| {
345        if lowered.starts_with(key) {
346            let separator = lowered.as_bytes().get(key.len()).copied()?;
347            if separator == b'=' || separator == b':' {
348                return Some(key.len() + 1);
349            }
350        }
351        None
352    })
353}
354
355fn find_json_string_end(value: &str) -> Option<usize> {
356    let bytes = value.as_bytes();
357    if bytes.first().copied()? != b'"' {
358        return None;
359    }
360
361    let mut index = 1usize;
362    let mut escaped = false;
363    while index < bytes.len() {
364        let byte = bytes[index];
365        if escaped {
366            escaped = false;
367            index += 1;
368            continue;
369        }
370        match byte {
371            b'\\' => escaped = true,
372            b'"' => return Some(index + 1),
373            _ => {}
374        }
375        index += 1;
376    }
377    None
378}
379
380fn is_normalized_transport_selector(selector: &str) -> bool {
381    let trimmed = selector.trim();
382    if trimmed.is_empty() {
383        return false;
384    }
385
386    let mut index = 0usize;
387    while index < trimmed.len() {
388        let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
389            return false;
390        };
391
392        index += prefix_len;
393        let remainder = &trimmed[index..];
394        if !remainder.starts_with('"') {
395            return false;
396        }
397
398        let Some(json_end) = find_json_string_end(remainder) else {
399            return false;
400        };
401        index += json_end;
402
403        if index == trimmed.len() {
404            return true;
405        }
406
407        let whitespace_len = trimmed[index..]
408            .chars()
409            .take_while(|char| char.is_ascii_whitespace())
410            .count();
411        if whitespace_len == 0 {
412            return false;
413        }
414        index += whitespace_len;
415
416        if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
417            return false;
418        }
419    }
420
421    true
422}
423
424fn decode_selector_body(body: &str) -> String {
425    let candidate = body.trim();
426    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
427        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
428            return unescape_shell_escaped_selector(&decoded);
429        }
430    }
431    unescape_shell_escaped_selector(candidate)
432}
433
434fn unescape_shell_escaped_selector(value: &str) -> String {
435    let mut result = String::with_capacity(value.len());
436    let mut chars = value.chars().peekable();
437    while let Some(ch) = chars.next() {
438        if ch == '\\' {
439            match chars.peek().copied() {
440                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
441                    result.push(chars.next().expect("peeked char should exist"));
442                    continue;
443                }
444                _ => {}
445            }
446        }
447        result.push(ch);
448    }
449    result
450}
451
452pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
453    let trimmed = selector.trim();
454    let lowered = trimmed.to_ascii_lowercase();
455    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
456        return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
457    }
458    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
459        return (
460            SelectorFlavor::UiAutomator,
461            decode_selector_body(&trimmed[4..]),
462        );
463    }
464    if let Some(prefix_len) = uiautomator_selector_prefix_len(&lowered) {
465        return (SelectorFlavor::UiAutomator, trimmed[..prefix_len - 1].to_string() + "=" + &decode_selector_body(&trimmed[prefix_len..]));
466    }
467    if lowered.starts_with("text=") || lowered.starts_with("text:") {
468        let body = decode_selector_body(&trimmed[5..]);
469        return (SelectorFlavor::UiAutomator, format!("text={body}"));
470    }
471    if lowered.starts_with("id=") || lowered.starts_with("id:") {
472        let body = decode_selector_body(&trimmed[3..]);
473        let normalized = if body.starts_with('#') {
474            body
475        } else {
476            format!("#{body}")
477        };
478        return (SelectorFlavor::Css, normalized);
479    }
480    if lowered.starts_with("css=") || lowered.starts_with("css:") {
481        return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
482    }
483    if trimmed.starts_with("//")
484        || trimmed.starts_with(".//")
485        || trimmed.starts_with("../")
486        || trimmed.starts_with('/')
487        || trimmed.starts_with('(')
488    {
489        return (SelectorFlavor::XPath, trimmed.to_string());
490    }
491    (SelectorFlavor::Css, trimmed.to_string())
492}
493
494pub fn normalize_selector_for_transport(selector: &str) -> String {
495    let trimmed = selector.trim();
496    if trimmed.is_empty() {
497        return String::new();
498    }
499    if is_normalized_transport_selector(trimmed) {
500        return trimmed.to_string();
501    }
502    let (flavor, body) = parse_selector_for_transport(selector);
503    format!(
504        "{}={}",
505        flavor.as_str(),
506        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
507    )
508}
509
510pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
511    let parent_selector = if parent.trim().is_empty() {
512        String::new()
513    } else {
514        normalize_selector_for_transport(parent)
515    };
516    let child_selector = if child.trim().is_empty() {
517        String::new()
518    } else {
519        normalize_selector_for_transport(child)
520    };
521    if parent_selector.is_empty() {
522        return child_selector;
523    }
524    if child_selector.is_empty() {
525        return parent_selector;
526    }
527    format!("{parent_selector} {child_selector}")
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    #[tokio::test]
535    async fn boots_mobile_runtime() {
536        assert_eq!(boot().await, "mobile ready");
537    }
538
539    #[tokio::test]
540    async fn boots_named_mobile_surface() {
541        assert_eq!(boot_surface("android", 1).await, "android ready");
542    }
543
544    #[test]
545    fn normalizes_xpath_and_css_like_web_clients() {
546        assert_eq!(
547            normalize_selector_for_transport("xpath=//android.widget.TextView"),
548            "xpath=\"//android.widget.TextView\""
549        );
550        assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
551        assert_eq!(
552            normalize_selector_for_transport("Id=bottom_nav_account"),
553            "css=\"#bottom_nav_account\""
554        );
555        assert_eq!(
556            normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
557            "css=\"#bottom_nav_account\""
558        );
559        assert_eq!(
560            normalize_selector_for_transport("text=Account"),
561            "uia=\"text=Account\""
562        );
563        assert_eq!(
564            normalize_selector_for_transport("textContains=Account"),
565            "uia=\"textContains=Account\""
566        );
567        assert_eq!(
568            normalize_selector_for_transport("resourceId=com.example:id/login"),
569            "uia=\"resourceId=com.example:id/login\""
570        );
571        assert_eq!(
572            normalize_selector_for_transport("descriptionContains=Account"),
573            "uia=\"descriptionContains=Account\""
574        );
575        assert_eq!(
576            normalize_selector_for_transport("selected=true"),
577            "uia=\"selected=true\""
578        );
579        assert_eq!(
580            normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
581            "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
582        );
583    }
584
585    #[test]
586    fn chains_mobile_locators_like_web_locators() {
587        let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
588        let child = parent.chain("css=.cta");
589        assert_eq!(
590            child.selector,
591            "xpath=\"//android.view.ViewGroup\" css=\".cta\""
592        );
593    }
594
595    #[test]
596    fn mobile_connect_command_returns_web_like_session_shape() {
597        let command = MobileCommand::Connect(ConnectOptions {
598            platform: MobilePlatform::Android,
599            device: Some("emulator-5554".to_string()),
600            adb_endpoint: None,
601            preserve_app_state: true,
602            timeout_ms: Some(5_000),
603        });
604
605        match command {
606            MobileCommand::Connect(options) => {
607                assert_eq!(options.platform, MobilePlatform::Android);
608                assert_eq!(options.device.as_deref(), Some("emulator-5554"));
609            }
610            _ => panic!("expected connect command"),
611        }
612    }
613
614    #[test]
615    fn mobile_launch_command_keeps_launch_shape_separate() {
616        let browser_session = MobileBrowserSessionHandle {
617            platform: MobilePlatform::Android,
618            automation: MobileAutomationSessionInfo {
619                backend: "uiautomator2".to_string(),
620                session_id: "uiautomator2:emulator-5554".to_string(),
621                note: "ready".to_string(),
622            },
623            device: DeviceTarget {
624                platform: MobilePlatform::Android,
625                device_id: "emulator-5554".to_string(),
626                connection_kind: DeviceConnectionKind::Emulator,
627            },
628        };
629
630        let command = MobileCommand::LaunchApp {
631            browser_session,
632            options: LaunchOptions {
633                apk_path: Some("/tmp/app.apk".to_string()),
634                app_id: Some("dev.allwright.sample".to_string()),
635                launch_activity: Some(".MainActivity".to_string()),
636                stop_before_launch: true,
637                timeout_ms: Some(15_000),
638            },
639        };
640
641        match command {
642            MobileCommand::LaunchApp { options, .. } => {
643                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
644                assert!(options.stop_before_launch);
645            }
646            _ => panic!("expected launch command"),
647        }
648    }
649}