Skip to main content

allwright/
client_mobile.rs

1use serde::{Deserialize, Serialize};
2
3use super::bootstrap::{ensure_plugins_installed, invoke_plugin};
4use super::types::{ClickResult, CommandOptions, Error, FillResult, Result};
5
6#[derive(Debug, Clone, Default)]
7pub struct MobileAndroidConnectOptions {
8    pub device: Option<String>,
9    pub adb_endpoint: Option<String>,
10    pub preserve_app_state: bool,
11    pub timeout_ms: Option<u32>,
12}
13
14#[derive(Debug, Clone, Default)]
15pub struct MobileAndroidLaunchOptions {
16    pub apk_path: Option<String>,
17    pub app_id: Option<String>,
18    pub launch_activity: Option<String>,
19    pub stop_before_launch: bool,
20    pub timeout_ms: Option<u32>,
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24struct MobilePluginEnvelope<T> {
25    ok: bool,
26    result: Option<T>,
27    error: Option<String>,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31struct MobileBrowserSessionHandle {
32    platform: String,
33    automation: MobileAutomationSessionInfo,
34    device: MobileDeviceTarget,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38struct MobileAutomationSessionInfo {
39    session_id: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43struct MobileDeviceTarget {
44    device_id: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct MobilePageSessionHandle {
49    page_id: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct MobilePageInfo {
54    page_session: MobilePageSessionHandle,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct MobileConnectInfo {
59    browser_session: MobileBrowserSessionHandle,
60    initial_page: MobilePageInfo,
61}
62
63#[derive(Debug, Clone)]
64pub struct AndroidLocator {
65    page: AndroidPage,
66    selector: String,
67}
68
69#[derive(Debug, Clone)]
70pub struct AndroidPage {
71    browser_session: MobileBrowserSessionHandle,
72    page_session: MobilePageSessionHandle,
73}
74
75#[derive(Debug, Clone)]
76pub struct AndroidDevice {
77    connect_info: MobileConnectInfo,
78    page: AndroidPage,
79}
80
81pub mod android {
82    use super::*;
83
84    pub fn connect(options: MobileAndroidConnectOptions) -> Result<AndroidDevice> {
85        ensure_plugins_installed(&["mobile-android"])?;
86        let request = serde_json::json!({
87            "command": "connect",
88            "platform": "android",
89            "device": options.device,
90            "adb_endpoint": options.adb_endpoint,
91            "preserve_app_state": options.preserve_app_state,
92            "timeout_ms": options.timeout_ms,
93        });
94        let connect_info: MobileConnectInfo = invoke_android("connect", request)?;
95        Ok(AndroidDevice::new(connect_info))
96    }
97}
98
99impl AndroidDevice {
100    fn new(connect_info: MobileConnectInfo) -> Self {
101        let page = AndroidPage {
102            browser_session: connect_info.browser_session.clone(),
103            page_session: connect_info.initial_page.page_session.clone(),
104        };
105        Self { connect_info, page }
106    }
107
108    pub fn session_id(&self) -> &str {
109        &self.connect_info.browser_session.automation.session_id
110    }
111
112    pub fn page(&self) -> AndroidPage {
113        self.page.clone()
114    }
115
116    pub fn initial_page(&self) -> AndroidPage {
117        self.page()
118    }
119
120    pub fn launch(&mut self, options: MobileAndroidLaunchOptions) -> Result<AndroidPage> {
121        let request = serde_json::json!({
122            "command": "launch_app",
123            "browser_session": self.connect_info.browser_session,
124            "options": {
125                "apk_path": options.apk_path,
126                "app_id": options.app_id,
127                "launch_activity": options.launch_activity,
128                "stop_before_launch": options.stop_before_launch,
129                "timeout_ms": options.timeout_ms,
130            },
131        });
132        let page_info: MobilePageInfo = invoke_android("launch", request)?;
133        self.page = AndroidPage {
134            browser_session: self.connect_info.browser_session.clone(),
135            page_session: page_info.page_session,
136        };
137        Ok(self.page.clone())
138    }
139}
140
141impl AndroidPage {
142    pub fn session_id(&self) -> &str {
143        &self.page_session.page_id
144    }
145
146    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
147        AndroidLocator {
148            page: self.clone(),
149            selector: normalize_mobile_selector_for_transport(&selector.into()),
150        }
151    }
152
153    pub fn click(&self, selector: &str, options: CommandOptions) -> Result<ClickResult> {
154        #[derive(Deserialize)]
155        struct ClickInfo {
156            selector: String,
157            note: String,
158            session_id: String,
159        }
160        let result: ClickInfo = invoke_android(
161            "click",
162            serde_json::json!({
163                "command": "click_element",
164                "browser_session": self.browser_session,
165                "page_session": self.page_session,
166                "selector": normalize_mobile_selector_for_transport(selector),
167                "timeout_ms": options.timeout_ms,
168            }),
169        )?;
170        Ok(ClickResult {
171            selector: result.selector,
172            note: result.note,
173            bidi_session_id: result.session_id,
174        })
175    }
176
177    pub fn fill(&self, selector: &str, value: &str, options: CommandOptions) -> Result<FillResult> {
178        #[derive(Deserialize)]
179        struct FillInfo {
180            selector: String,
181            value: String,
182            note: String,
183        }
184        let result: FillInfo = invoke_android(
185            "fill",
186            serde_json::json!({
187                "command": "fill_element",
188                "browser_session": self.browser_session,
189                "page_session": self.page_session,
190                "selector": normalize_mobile_selector_for_transport(selector),
191                "value": value,
192                "timeout_ms": options.timeout_ms,
193            }),
194        )?;
195        Ok(FillResult {
196            selector: result.selector,
197            value: result.value,
198            note: result.note,
199        })
200    }
201}
202
203impl AndroidLocator {
204    pub fn page(&self) -> &AndroidPage {
205        &self.page
206    }
207
208    pub fn selector(&self) -> &str {
209        &self.selector
210    }
211
212    pub fn locator(&self, selector: impl Into<String>) -> AndroidLocator {
213        AndroidLocator {
214            page: self.page.clone(),
215            selector: chain_mobile_selector_for_transport(&self.selector, &selector.into()),
216        }
217    }
218
219    pub fn click(&self, options: CommandOptions) -> Result<ClickResult> {
220        self.page.click(&self.selector, options)
221    }
222
223    pub fn fill(&self, value: &str, options: CommandOptions) -> Result<FillResult> {
224        self.page.fill(&self.selector, value, options)
225    }
226}
227
228fn invoke_android<T>(command_name: &str, request: serde_json::Value) -> Result<T>
229where
230    T: for<'de> Deserialize<'de>,
231{
232    let payload = invoke_plugin("mobile-android", &request.to_string())?;
233    let envelope: MobilePluginEnvelope<T> =
234        serde_json::from_str(payload.trim()).map_err(|error| {
235            Error::new(format!(
236                "failed to decode mobile-android plugin response for {command_name}: {error}"
237            ))
238        })?;
239    if !envelope.ok {
240        return Err(Error::new(envelope.error.unwrap_or_else(|| {
241            format!("mobile-android plugin {command_name} failed")
242        })));
243    }
244    envelope.result.ok_or_else(|| {
245        Error::new(format!(
246            "mobile-android plugin {command_name} returned success without a result payload"
247        ))
248    })
249}
250
251const UIAUTOMATOR_SELECTOR_KEYS: &[&str] = &[
252    "text",
253    "textcontains",
254    "textmatches",
255    "textstartswith",
256    "classname",
257    "classnamematches",
258    "description",
259    "desc",
260    "descriptioncontains",
261    "desccontains",
262    "descriptionmatches",
263    "descmatches",
264    "descriptionstartswith",
265    "descstartswith",
266    "checkable",
267    "checked",
268    "clickable",
269    "longclickable",
270    "scrollable",
271    "enabled",
272    "focusable",
273    "focused",
274    "selected",
275    "packagename",
276    "package",
277    "packagenamematches",
278    "resourceid",
279    "resourceidmatches",
280    "index",
281    "instance",
282];
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285enum MobileSelectorFlavor {
286    Css,
287    XPath,
288    UiAutomator,
289}
290
291impl MobileSelectorFlavor {
292    fn as_str(self) -> &'static str {
293        match self {
294            Self::Css => "css",
295            Self::XPath => "xpath",
296            Self::UiAutomator => "uia",
297        }
298    }
299}
300
301fn parse_explicit_mobile_selector_prefix(selector: &str) -> Option<(MobileSelectorFlavor, usize)> {
302    let lowered = selector.to_ascii_lowercase();
303    if lowered.starts_with("xpath=") || lowered.starts_with("xpath:") {
304        return Some((MobileSelectorFlavor::XPath, 6));
305    }
306    if lowered.starts_with("css=") || lowered.starts_with("css:") {
307        return Some((MobileSelectorFlavor::Css, 4));
308    }
309    if lowered.starts_with("uia=") || lowered.starts_with("uia:") {
310        return Some((MobileSelectorFlavor::UiAutomator, 4));
311    }
312    None
313}
314
315fn parse_uiautomator_selector_prefix(selector: &str) -> Option<usize> {
316    for (index, ch) in selector.char_indices() {
317        if ch != '=' && ch != ':' {
318            continue;
319        }
320        let key = selector[..index].trim().to_ascii_lowercase();
321        if UIAUTOMATOR_SELECTOR_KEYS
322            .iter()
323            .any(|candidate| *candidate == key)
324        {
325            return Some(index + ch.len_utf8());
326        }
327        return None;
328    }
329    None
330}
331
332fn find_json_string_end(value: &str) -> Option<usize> {
333    let bytes = value.as_bytes();
334    if bytes.first().copied()? != b'"' {
335        return None;
336    }
337    let mut index = 1usize;
338    let mut escaped = false;
339    while index < bytes.len() {
340        let byte = bytes[index];
341        if escaped {
342            escaped = false;
343            index += 1;
344            continue;
345        }
346        match byte {
347            b'\\' => escaped = true,
348            b'"' => return Some(index + 1),
349            _ => {}
350        }
351        index += 1;
352    }
353    None
354}
355
356fn is_normalized_mobile_transport_selector(selector: &str) -> bool {
357    let trimmed = selector.trim();
358    if trimmed.is_empty() {
359        return false;
360    }
361
362    let mut index = 0usize;
363    while index < trimmed.len() {
364        let Some((_, prefix_len)) = parse_explicit_mobile_selector_prefix(&trimmed[index..]) else {
365            return false;
366        };
367        index += prefix_len;
368
369        let remainder = &trimmed[index..];
370        let Some(json_end) = find_json_string_end(remainder) else {
371            return false;
372        };
373        index += json_end;
374        if index == trimmed.len() {
375            return true;
376        }
377
378        let whitespace_len = trimmed[index..]
379            .chars()
380            .take_while(|char| char.is_ascii_whitespace())
381            .count();
382        if whitespace_len == 0 {
383            return false;
384        }
385        index += whitespace_len;
386        if parse_explicit_mobile_selector_prefix(&trimmed[index..]).is_none() {
387            return false;
388        }
389    }
390
391    true
392}
393
394fn decode_selector_body(body: &str) -> String {
395    let candidate = body.trim();
396    if candidate.len() >= 2 && candidate.starts_with('"') && candidate.ends_with('"') {
397        if let Ok(decoded) = serde_json::from_str::<String>(candidate) {
398            return decoded;
399        }
400    }
401    candidate.to_string()
402}
403
404fn parse_mobile_selector_for_transport(selector: &str) -> (MobileSelectorFlavor, String) {
405    let trimmed = selector.trim();
406    if let Some((flavor, prefix_len)) = parse_explicit_mobile_selector_prefix(trimmed) {
407        return (flavor, decode_selector_body(&trimmed[prefix_len..]));
408    }
409    if let Some(prefix_len) = parse_uiautomator_selector_prefix(trimmed) {
410        return (
411            MobileSelectorFlavor::UiAutomator,
412            format!("{}={}", &trimmed[..prefix_len - 1], &trimmed[prefix_len..]),
413        );
414    }
415    if trimmed.starts_with("//")
416        || trimmed.starts_with(".//")
417        || trimmed.starts_with("../")
418        || trimmed.starts_with('/')
419        || trimmed.starts_with('(')
420    {
421        return (MobileSelectorFlavor::XPath, trimmed.to_string());
422    }
423    (MobileSelectorFlavor::Css, trimmed.to_string())
424}
425
426fn normalize_mobile_selector_for_transport(selector: &str) -> String {
427    let trimmed = selector.trim();
428    if trimmed.is_empty() {
429        return String::new();
430    }
431    if is_normalized_mobile_transport_selector(trimmed) {
432        return trimmed.to_string();
433    }
434    let (flavor, body) = parse_mobile_selector_for_transport(selector);
435    format!(
436        "{}={}",
437        flavor.as_str(),
438        serde_json::to_string(&body).unwrap_or_else(|_| format!("{body:?}"))
439    )
440}
441
442fn chain_mobile_selector_for_transport(parent: &str, child: &str) -> String {
443    let parent_selector = if parent.trim().is_empty() {
444        String::new()
445    } else {
446        normalize_mobile_selector_for_transport(parent)
447    };
448    let child_selector = if child.trim().is_empty() {
449        String::new()
450    } else {
451        normalize_mobile_selector_for_transport(child)
452    };
453    if parent_selector.is_empty() {
454        return child_selector;
455    }
456    if child_selector.is_empty() {
457        return parent_selector;
458    }
459    format!("{parent_selector} {child_selector}")
460}