Skip to main content

eoka_agent/
target.rs

1//! Live element targeting - resolves elements at action time via JS.
2
3use eoka::{Page, Result};
4use serde::Deserialize;
5
6/// Target selector - either an index or a live pattern.
7#[derive(Debug, Clone)]
8pub enum Target {
9    /// Element index from cached observe list
10    Index(usize),
11    /// Live pattern resolved via JS at action time
12    Live(LivePattern),
13}
14
15/// Live targeting patterns - all resolved via JS injection.
16#[derive(Debug, Clone)]
17pub enum LivePattern {
18    /// `text:Submit` - find by visible text
19    Text(String),
20    /// `placeholder:Enter code` - find by placeholder
21    Placeholder(String),
22    /// `role:button` - find by tag/ARIA role
23    Role(String),
24    /// `css:form button` - direct CSS selector
25    Css(String),
26    /// `id:submit-btn` - find by ID
27    Id(String),
28}
29
30impl Target {
31    /// Parse target string. Numbers become Index, everything else is Live.
32    pub fn parse(s: &str) -> Self {
33        let s = s.trim();
34
35        // Numbers are indices
36        if let Ok(idx) = s.parse::<usize>() {
37            return Target::Index(idx);
38        }
39
40        // Everything else is a live pattern
41        Target::Live(LivePattern::parse(s))
42    }
43}
44
45impl LivePattern {
46    /// Parse a live pattern. Unprefixed strings default to text search.
47    pub fn parse(s: &str) -> Self {
48        if let Some(v) = s.strip_prefix("text:") {
49            return LivePattern::Text(v.into());
50        }
51        if let Some(v) = s.strip_prefix("placeholder:") {
52            return LivePattern::Placeholder(v.into());
53        }
54        if let Some(v) = s.strip_prefix("role:") {
55            return LivePattern::Role(v.into());
56        }
57        if let Some(v) = s.strip_prefix("css:") {
58            return LivePattern::Css(v.into());
59        }
60        if let Some(v) = s.strip_prefix("id:") {
61            return LivePattern::Id(v.into());
62        }
63        // Default: treat as text search
64        LivePattern::Text(s.into())
65    }
66
67    fn as_js_args(&self) -> (&'static str, &str) {
68        match self {
69            LivePattern::Text(v) => ("text", v),
70            LivePattern::Placeholder(v) => ("placeholder", v),
71            LivePattern::Role(v) => ("role", v),
72            LivePattern::Css(v) => ("css", v),
73            LivePattern::Id(v) => ("id", v),
74        }
75    }
76}
77
78/// Bounding box.
79#[derive(Debug, Deserialize, Clone, Default)]
80pub struct BBox {
81    pub x: f64,
82    pub y: f64,
83    pub width: f64,
84    pub height: f64,
85}
86
87/// Result from live resolution.
88#[derive(Debug, Deserialize)]
89pub struct Resolved {
90    pub selector: String,
91    pub tag: String,
92    pub text: String,
93    pub found: bool,
94    #[serde(default)]
95    pub error: Option<String>,
96    #[serde(default)]
97    pub bbox: BBox,
98}
99
100const RESOLVE_JS: &str = include_str!("js/resolve.js");
101
102/// Resolve a live pattern to element info via JS.
103pub async fn resolve(page: &Page, pattern: &LivePattern) -> Result<Resolved> {
104    let (t, v) = pattern.as_js_args();
105    let js = format!(
106        "{}({},{})",
107        RESOLVE_JS,
108        serde_json::to_string(t).unwrap(),
109        serde_json::to_string(v).unwrap()
110    );
111    page.evaluate(&js).await
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn parse_index() {
120        assert!(matches!(Target::parse("0"), Target::Index(0)));
121        assert!(matches!(Target::parse("15"), Target::Index(15)));
122        assert!(matches!(Target::parse("  42  "), Target::Index(42)));
123    }
124
125    #[test]
126    fn parse_live_prefixed() {
127        assert!(matches!(
128            Target::parse("text:Submit"),
129            Target::Live(LivePattern::Text(_))
130        ));
131        assert!(matches!(
132            Target::parse("placeholder:Email"),
133            Target::Live(LivePattern::Placeholder(_))
134        ));
135        assert!(matches!(
136            Target::parse("css:button"),
137            Target::Live(LivePattern::Css(_))
138        ));
139        assert!(matches!(
140            Target::parse("id:btn"),
141            Target::Live(LivePattern::Id(_))
142        ));
143        assert!(matches!(
144            Target::parse("role:button"),
145            Target::Live(LivePattern::Role(_))
146        ));
147    }
148
149    #[test]
150    fn parse_live_unprefixed() {
151        // Unprefixed non-numeric defaults to text search
152        assert!(matches!(
153            Target::parse("Submit"),
154            Target::Live(LivePattern::Text(_))
155        ));
156        assert!(matches!(
157            Target::parse("Click Me"),
158            Target::Live(LivePattern::Text(_))
159        ));
160    }
161
162    #[test]
163    fn parse_preserves_value() {
164        if let Target::Live(LivePattern::Text(v)) = Target::parse("Submit Form") {
165            assert_eq!(v, "Submit Form");
166        } else {
167            panic!("Expected Text");
168        }
169
170        if let Target::Live(LivePattern::Css(v)) = Target::parse("css:button.primary") {
171            assert_eq!(v, "button.primary");
172        } else {
173            panic!("Expected Css");
174        }
175
176        if let Target::Live(LivePattern::Placeholder(v)) = Target::parse("placeholder:Enter email")
177        {
178            assert_eq!(v, "Enter email");
179        } else {
180            panic!("Expected Placeholder");
181        }
182    }
183
184    #[test]
185    fn as_js_args() {
186        assert_eq!(
187            LivePattern::Text("foo".into()).as_js_args(),
188            ("text", "foo")
189        );
190        assert_eq!(
191            LivePattern::Placeholder("bar".into()).as_js_args(),
192            ("placeholder", "bar")
193        );
194        assert_eq!(
195            LivePattern::Css("div.x".into()).as_js_args(),
196            ("css", "div.x")
197        );
198        assert_eq!(LivePattern::Id("myid".into()).as_js_args(), ("id", "myid"));
199        assert_eq!(
200            LivePattern::Role("button".into()).as_js_args(),
201            ("role", "button")
202        );
203    }
204}