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}
140
141impl SelectorFlavor {
142    fn as_str(self) -> &'static str {
143        match self {
144            Self::Css => "css",
145            Self::XPath => "xpath",
146        }
147    }
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
151pub struct MobileLocator {
152    pub selector: String,
153}
154
155impl MobileLocator {
156    pub fn normalize(selector: &str) -> Self {
157        Self {
158            selector: normalize_selector_for_transport(selector),
159        }
160    }
161
162    pub fn chain(&self, child_selector: &str) -> Self {
163        Self {
164            selector: chain_selector_for_transport(&self.selector, child_selector),
165        }
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
170pub struct MobileClickInfo {
171    pub selector: String,
172    pub note: String,
173    pub session_id: String,
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177pub struct MobileElementCountInfo {
178    pub selector: String,
179    pub count: u32,
180    pub note: String,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
184pub struct MobileFillInfo {
185    pub selector: String,
186    pub value: String,
187    pub note: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
191pub struct MobileTextInfo {
192    pub selector: String,
193    pub text: String,
194    pub note: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
198pub struct MobileWaitForSelectorInfo {
199    pub selector: String,
200    pub visible: bool,
201    pub note: String,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
205#[serde(tag = "command", rename_all = "snake_case")]
206pub enum MobileCommand {
207    Connect(ConnectOptions),
208    LaunchApp {
209        browser_session: MobileBrowserSessionHandle,
210        options: LaunchOptions,
211    },
212    OpenPage {
213        browser_session: MobileBrowserSessionHandle,
214    },
215    ClosePage {
216        browser_session: MobileBrowserSessionHandle,
217        page_session: MobilePageSessionHandle,
218    },
219    ClickElement {
220        browser_session: MobileBrowserSessionHandle,
221        page_session: MobilePageSessionHandle,
222        selector: String,
223        timeout_ms: Option<u32>,
224    },
225    CountElements {
226        browser_session: MobileBrowserSessionHandle,
227        page_session: MobilePageSessionHandle,
228        selector: String,
229        timeout_ms: Option<u32>,
230    },
231    FillElement {
232        browser_session: MobileBrowserSessionHandle,
233        page_session: MobilePageSessionHandle,
234        selector: String,
235        value: String,
236        timeout_ms: Option<u32>,
237    },
238    GetText {
239        browser_session: MobileBrowserSessionHandle,
240        page_session: MobilePageSessionHandle,
241        selector: String,
242        timeout_ms: Option<u32>,
243    },
244    WaitForSelector {
245        browser_session: MobileBrowserSessionHandle,
246        page_session: MobilePageSessionHandle,
247        selector: String,
248        visible: bool,
249        timeout_ms: Option<u32>,
250    },
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
254#[serde(tag = "result", rename_all = "snake_case")]
255pub enum MobileCommandResult {
256    Connect(MobileConnectInfo),
257    LaunchApp(MobilePageInfo),
258    OpenPage(MobilePageInfo),
259    ClosePage,
260    ClickElement(MobileClickInfo),
261    CountElements(MobileElementCountInfo),
262    FillElement(MobileFillInfo),
263    GetText(MobileTextInfo),
264    WaitForSelector(MobileWaitForSelectorInfo),
265}
266
267pub fn shared_descriptor() -> SurfacePluginDescriptor {
268    SurfacePluginDescriptor {
269        id: SURFACE_ID,
270        family: SurfaceFamily::Mobile,
271        version: env!("CARGO_PKG_VERSION"),
272        description: "Shared mobile surface abstractions for Android and iOS plugins.",
273    }
274}
275
276pub async fn boot_surface(label: &str, delay_ms: u64) -> String {
277    sleep(Duration::from_millis(delay_ms)).await;
278    format!("{label} ready")
279}
280
281pub async fn boot() -> String {
282    boot_surface("mobile", 25).await
283}
284
285fn parse_explicit_selector_prefix(selector: &str) -> Option<(SelectorFlavor, usize)> {
286    let lowered = selector.to_ascii_lowercase();
287    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
288        return Some((SelectorFlavor::XPath, 6));
289    }
290    if lowered.starts_with("id=") || lowered.starts_with("id:") {
291        return Some((SelectorFlavor::Css, 3));
292    }
293    if lowered.starts_with("css=") || lowered.starts_with("css:") {
294        return Some((SelectorFlavor::Css, 4));
295    }
296    None
297}
298
299fn find_json_string_end(value: &str) -> Option<usize> {
300    let bytes = value.as_bytes();
301    if bytes.first().copied()? != b'"' {
302        return None;
303    }
304
305    let mut index = 1usize;
306    let mut escaped = false;
307    while index < bytes.len() {
308        let byte = bytes[index];
309        if escaped {
310            escaped = false;
311            index += 1;
312            continue;
313        }
314        match byte {
315            b'\\' => escaped = true,
316            b'"' => return Some(index + 1),
317            _ => {}
318        }
319        index += 1;
320    }
321    None
322}
323
324fn is_normalized_transport_selector(selector: &str) -> bool {
325    let trimmed = selector.trim();
326    if trimmed.is_empty() {
327        return false;
328    }
329
330    let mut index = 0usize;
331    while index < trimmed.len() {
332        let Some((_, prefix_len)) = parse_explicit_selector_prefix(&trimmed[index..]) else {
333            return false;
334        };
335
336        index += prefix_len;
337        let remainder = &trimmed[index..];
338        if !remainder.starts_with('"') {
339            return false;
340        }
341
342        let Some(json_end) = find_json_string_end(remainder) else {
343            return false;
344        };
345        index += json_end;
346
347        if index == trimmed.len() {
348            return true;
349        }
350
351        let whitespace_len = trimmed[index..]
352            .chars()
353            .take_while(|char| char.is_ascii_whitespace())
354            .count();
355        if whitespace_len == 0 {
356            return false;
357        }
358        index += whitespace_len;
359
360        if parse_explicit_selector_prefix(&trimmed[index..]).is_none() {
361            return false;
362        }
363    }
364
365    true
366}
367
368fn decode_selector_body(body: &str) -> String {
369    let candidate = body.trim();
370    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
371        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
372            return unescape_shell_escaped_selector(&decoded);
373        }
374    }
375    unescape_shell_escaped_selector(candidate)
376}
377
378fn unescape_shell_escaped_selector(value: &str) -> String {
379    let mut result = String::with_capacity(value.len());
380    let mut chars = value.chars().peekable();
381    while let Some(ch) = chars.next() {
382        if ch == '\\' {
383            match chars.peek().copied() {
384                Some('_' | ' ' | '#' | ':' | '[' | ']' | '(' | ')' | '"' | '\'') => {
385                    result.push(chars.next().expect("peeked char should exist"));
386                    continue;
387                }
388                _ => {}
389            }
390        }
391        result.push(ch);
392    }
393    result
394}
395
396pub fn parse_selector_for_transport(selector: &str) -> (SelectorFlavor, String) {
397    let trimmed = selector.trim();
398    let lowered = trimmed.to_ascii_lowercase();
399    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
400        return (SelectorFlavor::XPath, decode_selector_body(&trimmed[6..]));
401    }
402    if lowered.starts_with("id=") || lowered.starts_with("id:") {
403        let body = decode_selector_body(&trimmed[3..]);
404        let normalized = if body.starts_with('#') {
405            body
406        } else {
407            format!("#{body}")
408        };
409        return (SelectorFlavor::Css, normalized);
410    }
411    if lowered.starts_with("css=") || lowered.starts_with("css:") {
412        return (SelectorFlavor::Css, decode_selector_body(&trimmed[4..]));
413    }
414    if trimmed.starts_with("//")
415        || trimmed.starts_with(".//")
416        || trimmed.starts_with("../")
417        || trimmed.starts_with('/')
418        || trimmed.starts_with('(')
419    {
420        return (SelectorFlavor::XPath, trimmed.to_string());
421    }
422    (SelectorFlavor::Css, trimmed.to_string())
423}
424
425pub fn normalize_selector_for_transport(selector: &str) -> String {
426    let trimmed = selector.trim();
427    if trimmed.is_empty() {
428        return String::new();
429    }
430    if is_normalized_transport_selector(trimmed) {
431        return trimmed.to_string();
432    }
433    let (flavor, body) = parse_selector_for_transport(selector);
434    format!(
435        "{}={}",
436        flavor.as_str(),
437        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
438    )
439}
440
441pub fn chain_selector_for_transport(parent: &str, child: &str) -> String {
442    let parent_selector = if parent.trim().is_empty() {
443        String::new()
444    } else {
445        normalize_selector_for_transport(parent)
446    };
447    let child_selector = if child.trim().is_empty() {
448        String::new()
449    } else {
450        normalize_selector_for_transport(child)
451    };
452    if parent_selector.is_empty() {
453        return child_selector;
454    }
455    if child_selector.is_empty() {
456        return parent_selector;
457    }
458    format!("{parent_selector} {child_selector}")
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[tokio::test]
466    async fn boots_mobile_runtime() {
467        assert_eq!(boot().await, "mobile ready");
468    }
469
470    #[tokio::test]
471    async fn boots_named_mobile_surface() {
472        assert_eq!(boot_surface("android", 1).await, "android ready");
473    }
474
475    #[test]
476    fn normalizes_xpath_and_css_like_web_clients() {
477        assert_eq!(
478            normalize_selector_for_transport("xpath=//android.widget.TextView"),
479            "xpath=\"//android.widget.TextView\""
480        );
481        assert_eq!(normalize_selector_for_transport("#login"), "css=\"#login\"");
482        assert_eq!(
483            normalize_selector_for_transport("Id=bottom_nav_account"),
484            "css=\"#bottom_nav_account\""
485        );
486        assert_eq!(
487            normalize_selector_for_transport(r"Id=bottom\_nav\_account"),
488            "css=\"#bottom_nav_account\""
489        );
490    }
491
492    #[test]
493    fn chains_mobile_locators_like_web_locators() {
494        let parent = MobileLocator::normalize("xpath=//android.view.ViewGroup");
495        let child = parent.chain("css=.cta");
496        assert_eq!(
497            child.selector,
498            "xpath=\"//android.view.ViewGroup\" css=\".cta\""
499        );
500    }
501
502    #[test]
503    fn mobile_connect_command_returns_web_like_session_shape() {
504        let command = MobileCommand::Connect(ConnectOptions {
505            platform: MobilePlatform::Android,
506            device: Some("emulator-5554".to_string()),
507            adb_endpoint: None,
508            preserve_app_state: true,
509            timeout_ms: Some(5_000),
510        });
511
512        match command {
513            MobileCommand::Connect(options) => {
514                assert_eq!(options.platform, MobilePlatform::Android);
515                assert_eq!(options.device.as_deref(), Some("emulator-5554"));
516            }
517            _ => panic!("expected connect command"),
518        }
519    }
520
521    #[test]
522    fn mobile_launch_command_keeps_launch_shape_separate() {
523        let browser_session = MobileBrowserSessionHandle {
524            platform: MobilePlatform::Android,
525            automation: MobileAutomationSessionInfo {
526                backend: "uiautomator2".to_string(),
527                session_id: "uiautomator2:emulator-5554".to_string(),
528                note: "ready".to_string(),
529            },
530            device: DeviceTarget {
531                platform: MobilePlatform::Android,
532                device_id: "emulator-5554".to_string(),
533                connection_kind: DeviceConnectionKind::Emulator,
534            },
535        };
536
537        let command = MobileCommand::LaunchApp {
538            browser_session,
539            options: LaunchOptions {
540                apk_path: Some("/tmp/app.apk".to_string()),
541                app_id: Some("dev.allwright.sample".to_string()),
542                launch_activity: Some(".MainActivity".to_string()),
543                stop_before_launch: true,
544                timeout_ms: Some(15_000),
545            },
546        };
547
548        match command {
549            MobileCommand::LaunchApp { options, .. } => {
550                assert_eq!(options.apk_path.as_deref(), Some("/tmp/app.apk"));
551                assert!(options.stop_before_launch);
552            }
553            _ => panic!("expected launch command"),
554        }
555    }
556}