1pub mod annotate;
26pub mod captcha;
27pub mod observe;
28pub mod spa;
29pub mod target;
30
31pub use spa::{RouterType, SpaRouterInfo};
32pub use target::{BBox, LivePattern, Resolved, Target};
33
34use std::collections::HashSet;
35use std::fmt;
36
37use eoka::{BoundingBox, Page, Result};
38
39pub use eoka::{Browser, Error, StealthConfig};
41
42#[derive(Debug, Clone)]
44pub struct InteractiveElement {
45 pub index: usize,
47 pub tag: String,
49 pub role: Option<String>,
51 pub text: String,
53 pub placeholder: Option<String>,
55 pub input_type: Option<String>,
57 pub selector: String,
59 pub checked: bool,
61 pub value: Option<String>,
63 pub bbox: BoundingBox,
65 pub fingerprint: u64,
67}
68
69impl InteractiveElement {
70 pub fn compute_fingerprint(
73 tag: &str,
74 text: &str,
75 role: Option<&str>,
76 input_type: Option<&str>,
77 placeholder: Option<&str>,
78 selector: &str,
79 ) -> u64 {
80 use std::collections::hash_map::DefaultHasher;
81 use std::hash::{Hash, Hasher};
82 let mut hasher = DefaultHasher::new();
83 tag.hash(&mut hasher);
84 text.hash(&mut hasher);
85 role.hash(&mut hasher);
86 input_type.hash(&mut hasher);
87 placeholder.hash(&mut hasher);
88 selector.hash(&mut hasher);
90 hasher.finish()
91 }
92}
93
94impl fmt::Display for InteractiveElement {
95 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96 write!(f, "[{}] <{}", self.index, self.tag)?;
97 if let Some(ref t) = self.input_type {
98 if t != "text" {
99 write!(f, " type=\"{}\"", t)?;
100 }
101 }
102 f.write_str(">")?;
103 if self.checked {
104 f.write_str(" [checked]")?;
105 }
106 if !self.text.is_empty() {
107 write!(f, " \"{}\"", self.text)?;
108 }
109 if let Some(ref v) = self.value {
110 write!(f, " value=\"{}\"", v)?;
111 }
112 if let Some(ref p) = self.placeholder {
113 write!(f, " placeholder=\"{}\"", p)?;
114 }
115 if let Some(ref r) = self.role {
116 let redundant = (r == "button" && self.tag == "button")
117 || (r == "link" && self.tag == "a")
118 || (r == "menuitem" && self.tag == "a");
119 if !redundant {
120 write!(f, " role=\"{}\"", r)?;
121 }
122 }
123 Ok(())
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct ObserveConfig {
130 pub viewport_only: bool,
133}
134
135impl Default for ObserveConfig {
136 fn default() -> Self {
137 Self {
138 viewport_only: true,
139 }
140 }
141}
142
143#[derive(Debug)]
145pub struct ObserveDiff {
146 pub added: Vec<usize>,
148 pub removed: usize,
150 pub total: usize,
152}
153
154impl fmt::Display for ObserveDiff {
155 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156 if self.added.is_empty() && self.removed == 0 {
157 write!(f, "no changes ({} elements)", self.total)
158 } else {
159 let mut need_sep = false;
160 if !self.added.is_empty() {
161 write!(f, "+{} added", self.added.len())?;
162 need_sep = true;
163 }
164 if self.removed > 0 {
165 if need_sep {
166 write!(f, ", ")?;
167 }
168 write!(f, "-{} removed", self.removed)?;
169 }
170 write!(f, " ({} total)", self.total)
171 }
172 }
173}
174
175pub struct Session {
182 browser: Browser,
183 page: Page,
184 elements: Vec<InteractiveElement>,
185 config: ObserveConfig,
186}
187
188impl Session {
189 pub async fn launch() -> Result<Self> {
191 let browser = Browser::launch().await?;
192 let page = browser.new_page("about:blank").await?;
193 Ok(Self {
194 browser,
195 page,
196 elements: Vec::new(),
197 config: ObserveConfig::default(),
198 })
199 }
200
201 pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
203 let browser = Browser::launch_with_config(stealth).await?;
204 let page = browser.new_page("about:blank").await?;
205 Ok(Self {
206 browser,
207 page,
208 elements: Vec::new(),
209 config: ObserveConfig::default(),
210 })
211 }
212
213 pub fn set_observe_config(&mut self, config: ObserveConfig) {
215 self.config = config;
216 }
217
218 pub fn page(&self) -> &Page {
220 &self.page
221 }
222
223 pub fn browser(&self) -> &Browser {
225 &self.browser
226 }
227
228 pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
234 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
235 Ok(&self.elements)
236 }
237
238 pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
240 if self.elements.is_empty() {
241 self.observe().await?;
242 }
243 annotate::annotated_screenshot(&self.page, &self.elements).await
244 }
245
246 pub fn element_list(&self) -> String {
248 let mut out = String::with_capacity(self.elements.len() * 40);
249 for el in &self.elements {
250 out.push_str(&el.to_string());
251 out.push('\n');
252 }
253 out
254 }
255
256 pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
258 self.elements.get(index)
259 }
260
261 pub fn elements(&self) -> &[InteractiveElement] {
263 &self.elements
264 }
265
266 pub fn len(&self) -> usize {
268 self.elements.len()
269 }
270
271 pub fn is_empty(&self) -> bool {
273 self.elements.is_empty()
274 }
275
276 pub fn find_by_text(&self, needle: &str) -> Option<usize> {
278 let needle_lower = needle.to_lowercase();
279 self.elements
280 .iter()
281 .find(|e| e.text.to_lowercase().contains(&needle_lower))
282 .map(|e| e.index)
283 }
284
285 pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
287 let needle_lower = needle.to_lowercase();
288 self.elements
289 .iter()
290 .filter(|e| e.text.to_lowercase().contains(&needle_lower))
291 .map(|e| e.index)
292 .collect()
293 }
294
295 pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
299 let old_selectors: HashSet<String> =
300 self.elements.iter().map(|e| e.selector.clone()).collect();
301
302 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
303
304 let new_selectors: HashSet<&str> =
305 self.elements.iter().map(|e| e.selector.as_str()).collect();
306
307 let added: Vec<usize> = self
308 .elements
309 .iter()
310 .filter(|e| !old_selectors.contains(&e.selector))
311 .map(|e| e.index)
312 .collect();
313
314 let removed = old_selectors
315 .iter()
316 .filter(|s| !new_selectors.contains(s.as_str()))
317 .count();
318
319 Ok(ObserveDiff {
320 added,
321 removed,
322 total: self.elements.len(),
323 })
324 }
325
326 pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
328 let mut out = String::new();
329 for &idx in &diff.added {
330 if let Some(el) = self.elements.get(idx) {
331 out.push_str(&el.to_string());
332 out.push('\n');
333 }
334 }
335 out
336 }
337
338 pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
340 self.page.screenshot().await
341 }
342
343 async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
350 let stored = self.elements.get(index).cloned();
352
353 if let Some(ref el) = stored {
354 let js = format!(
356 "!!document.querySelector({})",
357 serde_json::to_string(&el.selector).unwrap()
358 );
359 let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
360
361 if exists {
362 return self.elements.get(index).ok_or_else(|| {
363 eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
364 });
365 }
366
367 self.observe().await?;
369
370 if let Some(new_idx) = self
372 .elements
373 .iter()
374 .position(|e| e.fingerprint == el.fingerprint)
375 {
376 return Err(eoka::Error::ElementNotFound(format!(
378 "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
379 index, el.text, new_idx
380 )));
381 }
382
383 return Err(eoka::Error::ElementNotFound(format!(
384 "element [{}] \"{}\" no longer exists on page",
385 index, el.text
386 )));
387 }
388
389 Err(eoka::Error::ElementNotFound(format!(
390 "element [{}] not found (observed {} elements)",
391 index,
392 self.elements.len()
393 )))
394 }
395
396 pub async fn click(&mut self, index: usize) -> Result<()> {
399 let el = self.require_fresh(index).await?;
400 let selector = el.selector.clone();
401 self.page.click(&selector).await?;
402 self.wait_for_stable().await?;
403 self.elements.clear(); Ok(())
405 }
406
407 pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
410 let el = self.require_fresh(index).await?;
411 let selector = el.selector.clone();
412 self.page.fill(&selector, text).await?;
413 self.wait_for_stable().await?;
414 Ok(())
415 }
416
417 pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
420 let el = self.require_fresh(index).await?;
421 let selector = el.selector.clone();
422 let arg = serde_json::json!({ "sel": selector, "val": value });
423 let js = format!(
424 r#"(() => {{
425 const arg = {arg};
426 const sel = document.querySelector(arg.sel);
427 if (!sel) return false;
428 const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
429 if (!opt) return false;
430 sel.value = opt.value;
431 sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
432 return true;
433 }})()"#,
434 arg = serde_json::to_string(&arg).unwrap()
435 );
436 let selected: bool = self.page.evaluate(&js).await?;
437 if !selected {
438 return Err(eoka::Error::ElementNotFound(format!(
439 "option \"{}\" in element [{}]",
440 value, index
441 )));
442 }
443 self.wait_for_stable().await?;
444 self.elements.clear(); Ok(())
446 }
447
448 pub async fn hover(&mut self, index: usize) -> Result<()> {
450 let el = self.require_fresh(index).await?;
451 let cx = el.bbox.x + el.bbox.width / 2.0;
452 let cy = el.bbox.y + el.bbox.height / 2.0;
453 self.page
454 .session()
455 .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
456 .await
457 }
458
459 pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
461 let el = self.require_fresh(index).await?;
462 let selector = el.selector.clone();
463 let js = format!(
464 "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
465 serde_json::to_string(&selector).unwrap()
466 );
467 self.page.execute(&js).await
468 }
469
470 pub async fn try_click(&mut self, index: usize) -> Result<bool> {
472 let el = self.require_fresh(index).await?;
473 let selector = el.selector.clone();
474 self.page.try_click(&selector).await
475 }
476
477 pub async fn human_click(&mut self, index: usize) -> Result<()> {
479 let el = self.require_fresh(index).await?;
480 let selector = el.selector.clone();
481 self.page.human_click(&selector).await
482 }
483
484 pub async fn human_fill(&mut self, index: usize, text: &str) -> Result<()> {
486 let el = self.require_fresh(index).await?;
487 let selector = el.selector.clone();
488 self.page.human_fill(&selector, text).await
489 }
490
491 pub async fn focus(&mut self, index: usize) -> Result<()> {
493 let el = self.require_fresh(index).await?;
494 let selector = el.selector.clone();
495 self.page
496 .execute(&format!(
497 "document.querySelector({})?.focus()",
498 serde_json::to_string(&selector).unwrap()
499 ))
500 .await
501 }
502
503 pub async fn submit(&mut self, index: usize) -> Result<()> {
505 self.focus(index).await?;
506 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
507 self.page.human().press_key("Enter").await
508 }
509
510 pub async fn options(&mut self, index: usize) -> Result<Vec<(String, String)>> {
512 let el = self.require_fresh(index).await?;
513 let selector = el.selector.clone();
514 let js = format!(
515 r#"(() => {{
516 const sel = document.querySelector({});
517 if (!sel || !sel.options) return '[]';
518 return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
519 }})()"#,
520 serde_json::to_string(&selector).unwrap()
521 );
522 let json_str: String = self.page.evaluate(&js).await?;
523 let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
524 .map_err(|e| eoka::Error::CdpSimple(format!("options parse error: {}", e)))?;
525 Ok(pairs)
526 }
527
528 pub async fn goto(&mut self, url: &str) -> Result<()> {
534 self.elements.clear();
535 self.page.goto(url).await?;
536 self.wait_for_stable().await
537 }
538
539 pub async fn back(&mut self) -> Result<()> {
541 self.elements.clear();
542 self.page.back().await?;
543 self.wait_for_stable().await
544 }
545
546 pub async fn forward(&mut self) -> Result<()> {
548 self.elements.clear();
549 self.page.forward().await?;
550 self.wait_for_stable().await
551 }
552
553 pub async fn reload(&mut self) -> Result<()> {
555 self.elements.clear();
556 self.page.reload().await?;
557 self.wait_for_stable().await
558 }
559
560 pub async fn url(&self) -> Result<String> {
566 self.page.url().await
567 }
568
569 pub async fn title(&self) -> Result<String> {
571 self.page.title().await
572 }
573
574 pub async fn text(&self) -> Result<String> {
576 self.page.text().await
577 }
578
579 pub async fn scroll_down(&self) -> Result<()> {
585 self.page
586 .execute("window.scrollBy(0, window.innerHeight * 0.8)")
587 .await
588 }
589
590 pub async fn scroll_up(&self) -> Result<()> {
592 self.page
593 .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
594 .await
595 }
596
597 pub async fn scroll_to_top(&self) -> Result<()> {
599 self.page.execute("window.scrollTo(0, 0)").await
600 }
601
602 pub async fn scroll_to_bottom(&self) -> Result<()> {
604 self.page
605 .execute("window.scrollTo(0, document.body.scrollHeight)")
606 .await
607 }
608
609 pub async fn wait_for_stable(&self) -> Result<()> {
617 let _ = self.page.wait_for_network_idle(200, 2000).await;
619 self.page.wait(50).await;
621 Ok(())
622 }
623
624 pub async fn wait(&self, ms: u64) {
626 self.page.wait(ms).await;
627 }
628
629 pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
631 self.page.wait_for_text(text, timeout_ms).await?;
632 Ok(())
633 }
634
635 pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
637 self.page.wait_for_url_contains(pattern, timeout_ms).await
638 }
639
640 pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
642 self.page.wait_for_network_idle(500, timeout_ms).await
643 }
644
645 pub async fn press_key(&self, key: &str) -> Result<()> {
651 self.page.human().press_key(key).await
652 }
653
654 pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
660 self.page.evaluate(js).await
661 }
662
663 pub async fn exec(&self, js: &str) -> Result<()> {
665 self.page.execute(js).await
666 }
667
668 pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
681 let escaped_js = serde_json::to_string(js_expression)
682 .map_err(|e| eoka::Error::CdpSimple(format!("Failed to escape JS: {}", e)))?;
683 let js = format!("JSON.stringify(eval({}))", escaped_js);
684 let json_str: String = self.page.evaluate(&js).await?;
685 if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
686 return Err(eoka::Error::CdpSimple(format!(
687 "extract returned null/undefined for: {}",
688 if js_expression.len() > 60 {
689 &js_expression[..60]
690 } else {
691 js_expression
692 }
693 )));
694 }
695 serde_json::from_str(&json_str).map_err(|e| {
696 eoka::Error::CdpSimple(format!(
697 "extract parse error: {} (got: {})",
698 e,
699 if json_str.len() > 80 {
700 &json_str[..80]
701 } else {
702 &json_str
703 }
704 ))
705 })
706 }
707
708 pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
714 spa::detect_router(&self.page).await
715 }
716
717 pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
721 let info = spa::detect_router(&self.page).await?;
722 let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
723 self.elements.clear();
724 Ok(result)
725 }
726
727 pub async fn history_go(&mut self, delta: i32) -> Result<()> {
731 spa::history_go(&self.page, delta).await?;
732 self.elements.clear();
733 Ok(())
734 }
735
736 pub async fn close(self) -> Result<()> {
742 self.browser.close().await
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749
750 fn make_element(
751 index: usize,
752 tag: &str,
753 text: &str,
754 role: Option<&str>,
755 input_type: Option<&str>,
756 placeholder: Option<&str>,
757 value: Option<&str>,
758 checked: bool,
759 ) -> InteractiveElement {
760 let selector = format!("[data-idx=\"{}\"]", index);
761 let fingerprint = InteractiveElement::compute_fingerprint(
762 tag,
763 text,
764 role,
765 input_type,
766 placeholder,
767 &selector,
768 );
769 InteractiveElement {
770 index,
771 tag: tag.to_string(),
772 text: text.to_string(),
773 role: role.map(|s| s.to_string()),
774 input_type: input_type.map(|s| s.to_string()),
775 placeholder: placeholder.map(|s| s.to_string()),
776 value: value.map(|s| s.to_string()),
777 checked,
778 selector,
779 bbox: BoundingBox {
780 x: 0.0,
781 y: 0.0,
782 width: 100.0,
783 height: 30.0,
784 },
785 fingerprint,
786 }
787 }
788
789 #[test]
790 fn test_element_display_basic() {
791 let el = make_element(0, "button", "Submit", None, None, None, None, false);
792 assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
793 }
794
795 #[test]
796 fn test_element_display_with_input_type() {
797 let el = make_element(0, "input", "", None, Some("text"), None, None, false);
799 assert_eq!(el.to_string(), "[0] <input>");
800
801 let el = make_element(0, "input", "", None, Some("password"), None, None, false);
803 assert_eq!(el.to_string(), "[0] <input type=\"password\">");
804 }
805
806 #[test]
807 fn test_element_display_with_placeholder() {
808 let el = make_element(
809 0,
810 "input",
811 "",
812 None,
813 Some("text"),
814 Some("Enter email"),
815 None,
816 false,
817 );
818 assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
819 }
820
821 #[test]
822 fn test_element_display_with_value() {
823 let el = make_element(
824 0,
825 "input",
826 "",
827 None,
828 Some("text"),
829 None,
830 Some("hello"),
831 false,
832 );
833 assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
834 }
835
836 #[test]
837 fn test_element_display_checked() {
838 let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
839 assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
840 }
841
842 #[test]
843 fn test_element_display_redundant_role_suppressed() {
844 let el = make_element(
846 0,
847 "button",
848 "Click",
849 Some("button"),
850 None,
851 None,
852 None,
853 false,
854 );
855 assert_eq!(el.to_string(), "[0] <button> \"Click\"");
856
857 let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
859 assert_eq!(el.to_string(), "[0] <a> \"Link\"");
860
861 let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
863 assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
864 }
865
866 #[test]
867 fn test_element_display_non_redundant_role_shown() {
868 let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
870 assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
871
872 let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
874 assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
875 }
876
877 #[test]
878 fn test_observe_diff_display_no_changes() {
879 let diff = ObserveDiff {
880 added: vec![],
881 removed: 0,
882 total: 5,
883 };
884 assert_eq!(diff.to_string(), "no changes (5 elements)");
885 }
886
887 #[test]
888 fn test_observe_diff_display_added_only() {
889 let diff = ObserveDiff {
890 added: vec![5, 6],
891 removed: 0,
892 total: 7,
893 };
894 assert_eq!(diff.to_string(), "+2 added (7 total)");
895 }
896
897 #[test]
898 fn test_observe_diff_display_removed_only() {
899 let diff = ObserveDiff {
900 added: vec![],
901 removed: 3,
902 total: 2,
903 };
904 assert_eq!(diff.to_string(), "-3 removed (2 total)");
905 }
906
907 #[test]
908 fn test_observe_diff_display_both() {
909 let diff = ObserveDiff {
910 added: vec![3, 4],
911 removed: 1,
912 total: 5,
913 };
914 assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
915 }
916
917 #[test]
918 fn test_observe_config_default() {
919 let config = ObserveConfig::default();
920 assert!(config.viewport_only);
921 }
922
923 #[test]
924 fn test_fingerprint_uses_full_selector() {
925 let base = "a".repeat(50);
927 let sel_a = format!("{}AAAA", base);
928 let sel_b = format!("{}BBBB", base);
929
930 let fp_a = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_a);
931 let fp_b = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_b);
932
933 assert_ne!(
934 fp_a, fp_b,
935 "selectors differing after char 50 should produce different fingerprints"
936 );
937 }
938}