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 (
466            SelectorFlavor::UiAutomator,
467            trimmed[..prefix_len - 1].to_string()
468                + "="
469                + &decode_selector_body(&trimmed[prefix_len..]),
470        );
471    }
472    if lowered.starts_with("text=") || lowered.starts_with("text:") {
473        let body = decode_selector_body(&trimmed[5..]);
474        return (SelectorFlavor::UiAutomator, format!("text={body}"));
475    }
476    if lowered.starts_with("id=") || lowered.starts_with("id:") {
477        let body = decode_selector_body(&trimmed[3..]);
478        let normalized = if body.starts_with('#') {
479            body
480        } else {
481            format!("#{body}")
482        };
483        return (SelectorFlavor::Css, normalized);
484    }
485    if lowered.starts_with("css=") || lowered.starts_with("css:") {
486        return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
487    }
488    if trimmed.starts_with("//")
489        || trimmed.starts_with(".//")
490        || trimmed.starts_with("../")
491        || trimmed.starts_with('/')
492        || trimmed.starts_with('(')
493    {
494        return (SelectorFlavor::XPath, trimmed.to_string());
495    }
496    (SelectorFlavor::Css, trimmed.to_string())
497}
498
499pub fn normalize_selector_for_transport(selector: &str) -> String {
500    let trimmed = selector.trim();
501    if trimmed.is_empty() {
502        return String::new();
503    }
504    if is_normalized_transport_selector(trimmed) {
505        return trimmed.to_string();
506    }
507    let (flavor, body) = parse_selector_for_transport(selector);
508    format!(
509        "{}={}",
510        flavor.as_str(),
511        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
512    )
513}
514
515pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
516    let parent_selector = if parent.trim().is_empty() {
517        String::new()
518    } else {
519        normalize_selector_for_transport(parent)
520    };
521    let child_selector = if child.trim().is_empty() {
522        String::new()
523    } else {
524        normalize_selector_for_transport(child)
525    };
526    if parent_selector.is_empty() {
527        return child_selector;
528    }
529    if child_selector.is_empty() {
530        return parent_selector;
531    }
532    format!("{parent_selector} {child_selector}")
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[tokio::test]
540    async fn boots_mobile_runtime() {
541        assert_eq!(boot().await, "mobile ready");
542    }
543
544    #[tokio::test]
545    async fn boots_named_mobile_surface() {
546        assert_eq!(boot_surface("android", 1).await, "android ready");
547    }
548
549    #[test]
550    fn normalizes_xpath_and_css_like_web_clients() {
551        assert_eq!(
552            normalize_selector_for_transport("xpath=//android.widget.TextView"),
553            "xpath=\"//android.widget.TextView\""
554        );
555        assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
556        assert_eq!(
557            normalize_selector_for_transport("Id=bottom_nav_account"),
558            "css=\"#bottom_nav_account\""
559        );
560        assert_eq!(
561            normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
562            "css=\"#bottom_nav_account\""
563        );
564        assert_eq!(
565            normalize_selector_for_transport("text=Account"),
566            "uia=\"text=Account\""
567        );
568        assert_eq!(
569            normalize_selector_for_transport("textContains=Account"),
570            "uia=\"textContains=Account\""
571        );
572        assert_eq!(
573            normalize_selector_for_transport("resourceId=com.example:id/login"),
574            "uia=\"resourceId=com.example:id/login\""
575        );
576        assert_eq!(
577            normalize_selector_for_transport("descriptionContains=Account"),
578            "uia=\"descriptionContains=Account\""
579        );
580        assert_eq!(
581            normalize_selector_for_transport("selected=true"),
582            "uia=\"selected=true\""
583        );
584        assert_eq!(
585            normalize_selector_for_transport("classNameMatches=android\\.widget\\..*"),
586            "uia=\"classNameMatches=android\\\\.widget\\\\..*\""
587        );
588    }
589
590    #[test]
591    fn chains_mobile_locators_like_web_locators() {
592        let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
593        let child = parent.chain("css=.cta");
594        assert_eq!(
595            child.selector,
596            "xpath=\"//android.view.ViewGroup\" css=\".cta\""
597        );
598    }
599
600    #[test]
601    fn mobile_connect_command_returns_web_like_session_shape() {
602        let command = MobileCommand::Connect(ConnectOptions {
603            platform: MobilePlatform::Android,
604            device: Some("emulator-5554".to_string()),
605            adb_endpoint: None,
606            preserve_app_state: true,
607            timeout_ms: Some(5_000),
608        });
609
610        match command {
611            MobileCommand::Connect(options) => {
612                assert_eq!(options.platform, MobilePlatform::Android);
613                assert_eq!(options.device.as_deref(), Some("emulator-5554"));
614            }
615            _ => panic!("expected connect command"),
616        }
617    }
618
619    #[test]
620    fn mobile_launch_command_keeps_launch_shape_separate() {
621        let browser_session = MobileBrowserSessionHandle {
622            platform: MobilePlatform::Android,
623            automation: MobileAutomationSessionInfo {
624                backend: "uiautomator2".to_string(),
625                session_id: "uiautomator2:emulator-5554".to_string(),
626                note: "ready".to_string(),
627            },
628            device: DeviceTarget {
629                platform: MobilePlatform::Android,
630                device_id: "emulator-5554".to_string(),
631                connection_kind: DeviceConnectionKind::Emulator,
632            },
633        };
634
635        let command = MobileCommand::LaunchApp {
636            browser_session,
637            options: LaunchOptions {
638                apk_path: Some("/tmp/app.apk".to_string()),
639                app_id: Some("dev.allwright.sample".to_string()),
640                launch_activity: Some(".MainActivity".to_string()),
641                stop_before_launch: true,
642                timeout_ms: Some(15_000),
643            },
644        };
645
646        match command {
647            MobileCommand::LaunchApp { options, .. } => {
648                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
649                assert!(options.stop_before_launch);
650            }
651            _ => panic!("expected launch command"),
652        }
653    }
654}