1use eoka::{Page, Result};
4use serde::Deserialize;
5
6#[derive(Debug, Clone)]
8pub enum Target {
9 Index(usize),
11 Live(LivePattern),
13}
14
15#[derive(Debug, Clone)]
17pub enum LivePattern {
18 Text(String),
20 Placeholder(String),
22 Role(String),
24 Css(String),
26 Id(String),
28}
29
30impl Target {
31 pub fn parse(s: &str) -> Self {
33 let s = s.trim();
34
35 if let Ok(idx) = s.parse::<usize>() {
37 return Target::Index(idx);
38 }
39
40 Target::Live(LivePattern::parse(s))
42 }
43}
44
45impl LivePattern {
46 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 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#[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#[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
164pub 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 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}