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 = r#"
101((type, value) => {
102    const lc = s => (s || '').toLowerCase().trim();
103    const valLc = lc(value);
104
105    function selector(el) {
106        if (el.id) return '#' + CSS.escape(el.id);
107        const path = [];
108        let n = el;
109        while (n && n.nodeType === 1) {
110            let s = n.tagName.toLowerCase();
111            if (n.id) { path.unshift('#' + CSS.escape(n.id)); break; }
112            const p = n.parentElement;
113            if (p) {
114                const sibs = [...p.children].filter(c => c.tagName === n.tagName);
115                if (sibs.length > 1) s += ':nth-of-type(' + (sibs.indexOf(n) + 1) + ')';
116            }
117            path.unshift(s);
118            n = p;
119        }
120        return path.join(' > ');
121    }
122
123    function text(el) {
124        return el.innerText?.trim() || el.value || el.getAttribute('aria-label') || el.title || el.placeholder || '';
125    }
126
127    function interactive() {
128        return [...document.querySelectorAll('a,button,input,select,textarea,[role="button"],[onclick],[tabindex]')]
129            .filter(el => {
130                const r = el.getBoundingClientRect();
131                const s = getComputedStyle(el);
132                return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none';
133            });
134    }
135
136    let el = null;
137    switch (type) {
138        case 'text':
139            el = interactive().find(e => lc(text(e)).includes(valLc));
140            break;
141        case 'placeholder':
142            el = document.querySelector(`input[placeholder*="${value}" i],textarea[placeholder*="${value}" i]`)
143                || interactive().find(e => lc(e.placeholder).includes(valLc));
144            break;
145        case 'role':
146            el = document.querySelector(valLc) || document.querySelector(`[role="${value}"]`)
147                || interactive().find(e => e.tagName.toLowerCase() === valLc || e.getAttribute('role') === value);
148            break;
149        case 'css':
150            el = document.querySelector(value);
151            break;
152        case 'id':
153            el = document.getElementById(value);
154            break;
155    }
156
157    if (!el) return { found: false, error: `${type}:${value} not found`, selector: '', tag: '', text: '', bbox: {x:0,y:0,width:0,height:0} };
158
159    const r = el.getBoundingClientRect();
160    return { found: true, selector: selector(el), tag: el.tagName.toLowerCase(), text: text(el).slice(0, 50), bbox: {x:r.x,y:r.y,width:r.width,height:r.height} };
161})
162"#;
163
164/// Resolve a live pattern to element info via JS.
165pub async fn resolve(page: &Page, pattern: &LivePattern) -> Result<Resolved> {
166    let (t, v) = pattern.as_js_args();
167    let js = format!(
168        "{}({},{})",
169        RESOLVE_JS,
170        serde_json::to_string(t).unwrap(),
171        serde_json::to_string(v).unwrap()
172    );
173    page.evaluate(&js).await
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn parse_index() {
182        assert!(matches!(Target::parse("0"), Target::Index(0)));
183        assert!(matches!(Target::parse("15"), Target::Index(15)));
184        assert!(matches!(Target::parse("  42  "), Target::Index(42)));
185    }
186
187    #[test]
188    fn parse_live_prefixed() {
189        assert!(matches!(
190            Target::parse("text:Submit"),
191            Target::Live(LivePattern::Text(_))
192        ));
193        assert!(matches!(
194            Target::parse("placeholder:Email"),
195            Target::Live(LivePattern::Placeholder(_))
196        ));
197        assert!(matches!(
198            Target::parse("css:button"),
199            Target::Live(LivePattern::Css(_))
200        ));
201        assert!(matches!(
202            Target::parse("id:btn"),
203            Target::Live(LivePattern::Id(_))
204        ));
205        assert!(matches!(
206            Target::parse("role:button"),
207            Target::Live(LivePattern::Role(_))
208        ));
209    }
210
211    #[test]
212    fn parse_live_unprefixed() {
213        // Unprefixed non-numeric defaults to text search
214        assert!(matches!(
215            Target::parse("Submit"),
216            Target::Live(LivePattern::Text(_))
217        ));
218        assert!(matches!(
219            Target::parse("Click Me"),
220            Target::Live(LivePattern::Text(_))
221        ));
222    }
223
224    #[test]
225    fn parse_preserves_value() {
226        if let Target::Live(LivePattern::Text(v)) = Target::parse("Submit Form") {
227            assert_eq!(v, "Submit Form");
228        } else {
229            panic!("Expected Text");
230        }
231
232        if let Target::Live(LivePattern::Css(v)) = Target::parse("css:button.primary") {
233            assert_eq!(v, "button.primary");
234        } else {
235            panic!("Expected Css");
236        }
237
238        if let Target::Live(LivePattern::Placeholder(v)) = Target::parse("placeholder:Enter email")
239        {
240            assert_eq!(v, "Enter email");
241        } else {
242            panic!("Expected Placeholder");
243        }
244    }
245
246    #[test]
247    fn as_js_args() {
248        assert_eq!(
249            LivePattern::Text("foo".into()).as_js_args(),
250            ("text", "foo")
251        );
252        assert_eq!(
253            LivePattern::Placeholder("bar".into()).as_js_args(),
254            ("placeholder", "bar")
255        );
256        assert_eq!(
257            LivePattern::Css("div.x".into()).as_js_args(),
258            ("css", "div.x")
259        );
260        assert_eq!(LivePattern::Id("myid".into()).as_js_args(), ("id", "myid"));
261        assert_eq!(
262            LivePattern::Role("button".into()).as_js_args(),
263            ("role", "button")
264        );
265    }
266}