1pub mod annotate;
26pub mod captcha;
27pub mod observe;
28pub mod snapshot;
29pub mod spa;
30pub mod target;
31
32pub use spa::{RouterType, SpaRouterInfo};
33pub use target::{BBox, LivePattern, Resolved, Target};
34
35use std::collections::HashSet;
36use std::fmt;
37
38use eoka::{BoundingBox, Page, Result};
39
40pub use eoka::{Browser, Error, StealthConfig};
42
43#[derive(Debug, Clone)]
45pub struct InteractiveElement {
46 pub index: usize,
48 pub tag: String,
50 pub role: Option<String>,
52 pub text: String,
54 pub placeholder: Option<String>,
56 pub input_type: Option<String>,
58 pub selector: String,
60 pub checked: bool,
62 pub value: Option<String>,
64 pub bbox: BoundingBox,
66 pub fingerprint: u64,
68}
69
70impl InteractiveElement {
71 pub fn compute_fingerprint(
74 tag: &str,
75 text: &str,
76 role: Option<&str>,
77 input_type: Option<&str>,
78 placeholder: Option<&str>,
79 selector: &str,
80 ) -> u64 {
81 use std::collections::hash_map::DefaultHasher;
82 use std::hash::{Hash, Hasher};
83 let mut hasher = DefaultHasher::new();
84 tag.hash(&mut hasher);
85 text.hash(&mut hasher);
86 role.hash(&mut hasher);
87 input_type.hash(&mut hasher);
88 placeholder.hash(&mut hasher);
89 selector.hash(&mut hasher);
91 hasher.finish()
92 }
93}
94
95impl fmt::Display for InteractiveElement {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 write!(f, "[{}] <{}", self.index, self.tag)?;
98 if let Some(ref t) = self.input_type {
99 if t != "text" {
100 write!(f, " type=\"{}\"", t)?;
101 }
102 }
103 f.write_str(">")?;
104 if self.checked {
105 f.write_str(" [checked]")?;
106 }
107 if !self.text.is_empty() {
108 write!(f, " \"{}\"", self.text)?;
109 }
110 if let Some(ref v) = self.value {
111 write!(f, " value=\"{}\"", v)?;
112 }
113 if let Some(ref p) = self.placeholder {
114 write!(f, " placeholder=\"{}\"", p)?;
115 }
116 if let Some(ref r) = self.role {
117 let redundant = (r == "button" && self.tag == "button")
118 || (r == "link" && self.tag == "a")
119 || (r == "menuitem" && self.tag == "a");
120 if !redundant {
121 write!(f, " role=\"{}\"", r)?;
122 }
123 }
124 Ok(())
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct ObserveConfig {
131 pub viewport_only: bool,
134}
135
136impl Default for ObserveConfig {
137 fn default() -> Self {
138 Self {
139 viewport_only: true,
140 }
141 }
142}
143
144#[derive(Debug)]
146pub struct ObserveDiff {
147 pub added: Vec<usize>,
149 pub removed: usize,
151 pub total: usize,
153}
154
155impl fmt::Display for ObserveDiff {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 if self.added.is_empty() && self.removed == 0 {
158 write!(f, "no changes ({} elements)", self.total)
159 } else {
160 let mut need_sep = false;
161 if !self.added.is_empty() {
162 write!(f, "+{} added", self.added.len())?;
163 need_sep = true;
164 }
165 if self.removed > 0 {
166 if need_sep {
167 write!(f, ", ")?;
168 }
169 write!(f, "-{} removed", self.removed)?;
170 }
171 write!(f, " ({} total)", self.total)
172 }
173 }
174}
175
176pub struct Session {
183 browser: Browser,
184 page: Page,
185 elements: Vec<InteractiveElement>,
186 config: ObserveConfig,
187}
188
189impl Session {
190 pub async fn launch() -> Result<Self> {
192 let browser = Browser::launch().await?;
193 let page = browser.new_page("about:blank").await?;
194 Ok(Self {
195 browser,
196 page,
197 elements: Vec::new(),
198 config: ObserveConfig::default(),
199 })
200 }
201
202 pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
204 let browser = Browser::launch_with_config(stealth).await?;
205 let page = browser.new_page("about:blank").await?;
206 Ok(Self {
207 browser,
208 page,
209 elements: Vec::new(),
210 config: ObserveConfig::default(),
211 })
212 }
213
214 pub fn set_observe_config(&mut self, config: ObserveConfig) {
216 self.config = config;
217 }
218
219 pub fn page(&self) -> &Page {
221 &self.page
222 }
223
224 pub fn browser(&self) -> &Browser {
226 &self.browser
227 }
228
229 pub async fn ax_snapshot(&self, include_all: bool) -> anyhow::Result<snapshot::SnapshotResult> {
235 snapshot::snapshot(&self.page, include_all).await
236 }
237
238 pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
240 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
241 Ok(&self.elements)
242 }
243
244 pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
246 if self.elements.is_empty() {
247 self.observe().await?;
248 }
249 annotate::annotated_screenshot(&self.page, &self.elements).await
250 }
251
252 pub fn element_list(&self) -> String {
254 let mut out = String::with_capacity(self.elements.len() * 40);
255 for el in &self.elements {
256 out.push_str(&el.to_string());
257 out.push('\n');
258 }
259 out
260 }
261
262 pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
264 self.elements.get(index)
265 }
266
267 pub fn elements(&self) -> &[InteractiveElement] {
269 &self.elements
270 }
271
272 pub fn len(&self) -> usize {
274 self.elements.len()
275 }
276
277 pub fn is_empty(&self) -> bool {
279 self.elements.is_empty()
280 }
281
282 pub fn find_by_text(&self, needle: &str) -> Option<usize> {
284 let needle_lower = needle.to_lowercase();
285 self.elements
286 .iter()
287 .find(|e| e.text.to_lowercase().contains(&needle_lower))
288 .map(|e| e.index)
289 }
290
291 pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
293 let needle_lower = needle.to_lowercase();
294 self.elements
295 .iter()
296 .filter(|e| e.text.to_lowercase().contains(&needle_lower))
297 .map(|e| e.index)
298 .collect()
299 }
300
301 pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
305 let old_selectors: HashSet<String> =
306 self.elements.iter().map(|e| e.selector.clone()).collect();
307
308 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
309
310 let new_selectors: HashSet<&str> =
311 self.elements.iter().map(|e| e.selector.as_str()).collect();
312
313 let added: Vec<usize> = self
314 .elements
315 .iter()
316 .filter(|e| !old_selectors.contains(&e.selector))
317 .map(|e| e.index)
318 .collect();
319
320 let removed = old_selectors
321 .iter()
322 .filter(|s| !new_selectors.contains(s.as_str()))
323 .count();
324
325 Ok(ObserveDiff {
326 added,
327 removed,
328 total: self.elements.len(),
329 })
330 }
331
332 pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
334 let mut out = String::new();
335 for &idx in &diff.added {
336 if let Some(el) = self.elements.get(idx) {
337 out.push_str(&el.to_string());
338 out.push('\n');
339 }
340 }
341 out
342 }
343
344 pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
346 self.page.screenshot().await
347 }
348
349 async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
356 let stored = self.elements.get(index).cloned();
358
359 if let Some(ref el) = stored {
360 let js = format!(
362 "!!document.querySelector({})",
363 serde_json::to_string(&el.selector).unwrap()
364 );
365 let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
366
367 if exists {
368 return self.elements.get(index).ok_or_else(|| {
369 eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
370 });
371 }
372
373 self.observe().await?;
375
376 if let Some(new_idx) = self
378 .elements
379 .iter()
380 .position(|e| e.fingerprint == el.fingerprint)
381 {
382 return Err(eoka::Error::ElementNotFound(format!(
384 "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
385 index, el.text, new_idx
386 )));
387 }
388
389 return Err(eoka::Error::ElementNotFound(format!(
390 "element [{}] \"{}\" no longer exists on page",
391 index, el.text
392 )));
393 }
394
395 Err(eoka::Error::ElementNotFound(format!(
396 "element [{}] not found (observed {} elements)",
397 index,
398 self.elements.len()
399 )))
400 }
401
402 pub async fn click(&mut self, index: usize) -> Result<()> {
405 let el = self.require_fresh(index).await?;
406 let selector = el.selector.clone();
407 self.page.click(&selector).await?;
408 self.wait_for_stable().await?;
409 self.elements.clear(); Ok(())
411 }
412
413 pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
416 let el = self.require_fresh(index).await?;
417 let selector = el.selector.clone();
418 self.page.fill(&selector, text).await?;
419 self.wait_for_stable().await?;
420 Ok(())
421 }
422
423 pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
426 let el = self.require_fresh(index).await?;
427 let selector = el.selector.clone();
428 let arg = serde_json::json!({ "sel": selector, "val": value });
429 let js = format!(
430 r#"(() => {{
431 const arg = {arg};
432 const sel = document.querySelector(arg.sel);
433 if (!sel) return false;
434 const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
435 if (!opt) return false;
436 sel.value = opt.value;
437 sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
438 return true;
439 }})()"#,
440 arg = serde_json::to_string(&arg).unwrap()
441 );
442 let selected: bool = self.page.evaluate(&js).await?;
443 if !selected {
444 return Err(eoka::Error::ElementNotFound(format!(
445 "option \"{}\" in element [{}]",
446 value, index
447 )));
448 }
449 self.wait_for_stable().await?;
450 self.elements.clear(); Ok(())
452 }
453
454 pub async fn hover(&mut self, index: usize) -> Result<()> {
456 let el = self.require_fresh(index).await?;
457 let cx = el.bbox.x + el.bbox.width / 2.0;
458 let cy = el.bbox.y + el.bbox.height / 2.0;
459 self.page
460 .session()
461 .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
462 .await
463 }
464
465 pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
467 let el = self.require_fresh(index).await?;
468 let selector = el.selector.clone();
469 let js = format!(
470 "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
471 serde_json::to_string(&selector).unwrap()
472 );
473 self.page.execute(&js).await
474 }
475
476 pub async fn try_click(&mut self, index: usize) -> Result<bool> {
478 let el = self.require_fresh(index).await?;
479 let selector = el.selector.clone();
480 self.page.try_click(&selector).await
481 }
482
483 pub async fn human_click(&mut self, index: usize) -> Result<()> {
485 let el = self.require_fresh(index).await?;
486 let selector = el.selector.clone();
487 self.page.human_click(&selector).await
488 }
489
490 pub async fn human_fill(&mut self, index: usize, text: &str) -> Result<()> {
492 let el = self.require_fresh(index).await?;
493 let selector = el.selector.clone();
494 self.page.human_fill(&selector, text).await
495 }
496
497 pub async fn focus(&mut self, index: usize) -> Result<()> {
499 let el = self.require_fresh(index).await?;
500 let selector = el.selector.clone();
501 self.page
502 .execute(&format!(
503 "document.querySelector({})?.focus()",
504 serde_json::to_string(&selector).unwrap()
505 ))
506 .await
507 }
508
509 pub async fn submit(&mut self, index: usize) -> Result<()> {
511 self.focus(index).await?;
512 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
513 self.page.human().press_key("Enter").await
514 }
515
516 pub async fn options(&mut self, index: usize) -> Result<Vec<(String, String)>> {
518 let el = self.require_fresh(index).await?;
519 let selector = el.selector.clone();
520 let js = format!(
521 r#"(() => {{
522 const sel = document.querySelector({});
523 if (!sel || !sel.options) return '[]';
524 return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
525 }})()"#,
526 serde_json::to_string(&selector).unwrap()
527 );
528 let json_str: String = self.page.evaluate(&js).await?;
529 let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
530 .map_err(|e| eoka::Error::CdpSimple(format!("options parse error: {}", e)))?;
531 Ok(pairs)
532 }
533
534 pub async fn goto(&mut self, url: &str) -> Result<()> {
540 self.elements.clear();
541 self.page.goto(url).await?;
542 self.wait_for_stable().await
543 }
544
545 pub async fn back(&mut self) -> Result<()> {
547 self.elements.clear();
548 self.page.back().await?;
549 self.wait_for_stable().await
550 }
551
552 pub async fn forward(&mut self) -> Result<()> {
554 self.elements.clear();
555 self.page.forward().await?;
556 self.wait_for_stable().await
557 }
558
559 pub async fn reload(&mut self) -> Result<()> {
561 self.elements.clear();
562 self.page.reload().await?;
563 self.wait_for_stable().await
564 }
565
566 pub async fn url(&self) -> Result<String> {
572 self.page.url().await
573 }
574
575 pub async fn title(&self) -> Result<String> {
577 self.page.title().await
578 }
579
580 pub async fn text(&self) -> Result<String> {
582 self.page.text().await
583 }
584
585 pub async fn scroll_down(&self) -> Result<()> {
591 self.page
592 .execute("window.scrollBy(0, window.innerHeight * 0.8)")
593 .await
594 }
595
596 pub async fn scroll_up(&self) -> Result<()> {
598 self.page
599 .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
600 .await
601 }
602
603 pub async fn scroll_to_top(&self) -> Result<()> {
605 self.page.execute("window.scrollTo(0, 0)").await
606 }
607
608 pub async fn scroll_to_bottom(&self) -> Result<()> {
610 self.page
611 .execute("window.scrollTo(0, document.body.scrollHeight)")
612 .await
613 }
614
615 pub async fn wait_for_stable(&self) -> Result<()> {
623 let _ = self.page.wait_for_network_idle(200, 2000).await;
625 self.page.wait(50).await;
627 Ok(())
628 }
629
630 pub async fn wait(&self, ms: u64) {
632 self.page.wait(ms).await;
633 }
634
635 pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
637 self.page.wait_for_text(text, timeout_ms).await?;
638 Ok(())
639 }
640
641 pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
643 self.page.wait_for_url_contains(pattern, timeout_ms).await
644 }
645
646 pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
648 self.page.wait_for_network_idle(500, timeout_ms).await
649 }
650
651 pub async fn press_key(&self, key: &str) -> Result<()> {
657 self.page.human().press_key(key).await
658 }
659
660 pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
666 self.page.evaluate(js).await
667 }
668
669 pub async fn exec(&self, js: &str) -> Result<()> {
671 self.page.execute(js).await
672 }
673
674 pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
687 let escaped_js = serde_json::to_string(js_expression)
688 .map_err(|e| eoka::Error::CdpSimple(format!("Failed to escape JS: {}", e)))?;
689 let js = format!("JSON.stringify(eval({}))", escaped_js);
690 let json_str: String = self.page.evaluate(&js).await?;
691 if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
692 return Err(eoka::Error::CdpSimple(format!(
693 "extract returned null/undefined for: {}",
694 if js_expression.len() > 60 {
695 &js_expression[..60]
696 } else {
697 js_expression
698 }
699 )));
700 }
701 serde_json::from_str(&json_str).map_err(|e| {
702 eoka::Error::CdpSimple(format!(
703 "extract parse error: {} (got: {})",
704 e,
705 if json_str.len() > 80 {
706 &json_str[..80]
707 } else {
708 &json_str
709 }
710 ))
711 })
712 }
713
714 pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
720 spa::detect_router(&self.page).await
721 }
722
723 pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
727 let info = spa::detect_router(&self.page).await?;
728 let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
729 self.elements.clear();
730 Ok(result)
731 }
732
733 pub async fn history_go(&mut self, delta: i32) -> Result<()> {
737 spa::history_go(&self.page, delta).await?;
738 self.elements.clear();
739 Ok(())
740 }
741
742 pub async fn close(self) -> Result<()> {
748 self.browser.close().await
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 fn make_element(
757 index: usize,
758 tag: &str,
759 text: &str,
760 role: Option<&str>,
761 input_type: Option<&str>,
762 placeholder: Option<&str>,
763 value: Option<&str>,
764 checked: bool,
765 ) -> InteractiveElement {
766 let selector = format!("[data-idx=\"{}\"]", index);
767 let fingerprint = InteractiveElement::compute_fingerprint(
768 tag,
769 text,
770 role,
771 input_type,
772 placeholder,
773 &selector,
774 );
775 InteractiveElement {
776 index,
777 tag: tag.to_string(),
778 text: text.to_string(),
779 role: role.map(|s| s.to_string()),
780 input_type: input_type.map(|s| s.to_string()),
781 placeholder: placeholder.map(|s| s.to_string()),
782 value: value.map(|s| s.to_string()),
783 checked,
784 selector,
785 bbox: BoundingBox {
786 x: 0.0,
787 y: 0.0,
788 width: 100.0,
789 height: 30.0,
790 },
791 fingerprint,
792 }
793 }
794
795 #[test]
796 fn test_element_display_basic() {
797 let el = make_element(0, "button", "Submit", None, None, None, None, false);
798 assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
799 }
800
801 #[test]
802 fn test_element_display_with_input_type() {
803 let el = make_element(0, "input", "", None, Some("text"), None, None, false);
805 assert_eq!(el.to_string(), "[0] <input>");
806
807 let el = make_element(0, "input", "", None, Some("password"), None, None, false);
809 assert_eq!(el.to_string(), "[0] <input type=\"password\">");
810 }
811
812 #[test]
813 fn test_element_display_with_placeholder() {
814 let el = make_element(
815 0,
816 "input",
817 "",
818 None,
819 Some("text"),
820 Some("Enter email"),
821 None,
822 false,
823 );
824 assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
825 }
826
827 #[test]
828 fn test_element_display_with_value() {
829 let el = make_element(
830 0,
831 "input",
832 "",
833 None,
834 Some("text"),
835 None,
836 Some("hello"),
837 false,
838 );
839 assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
840 }
841
842 #[test]
843 fn test_element_display_checked() {
844 let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
845 assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
846 }
847
848 #[test]
849 fn test_element_display_redundant_role_suppressed() {
850 let el = make_element(
852 0,
853 "button",
854 "Click",
855 Some("button"),
856 None,
857 None,
858 None,
859 false,
860 );
861 assert_eq!(el.to_string(), "[0] <button> \"Click\"");
862
863 let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
865 assert_eq!(el.to_string(), "[0] <a> \"Link\"");
866
867 let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
869 assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
870 }
871
872 #[test]
873 fn test_element_display_non_redundant_role_shown() {
874 let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
876 assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
877
878 let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
880 assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
881 }
882
883 #[test]
884 fn test_observe_diff_display_no_changes() {
885 let diff = ObserveDiff {
886 added: vec![],
887 removed: 0,
888 total: 5,
889 };
890 assert_eq!(diff.to_string(), "no changes (5 elements)");
891 }
892
893 #[test]
894 fn test_observe_diff_display_added_only() {
895 let diff = ObserveDiff {
896 added: vec![5, 6],
897 removed: 0,
898 total: 7,
899 };
900 assert_eq!(diff.to_string(), "+2 added (7 total)");
901 }
902
903 #[test]
904 fn test_observe_diff_display_removed_only() {
905 let diff = ObserveDiff {
906 added: vec![],
907 removed: 3,
908 total: 2,
909 };
910 assert_eq!(diff.to_string(), "-3 removed (2 total)");
911 }
912
913 #[test]
914 fn test_observe_diff_display_both() {
915 let diff = ObserveDiff {
916 added: vec![3, 4],
917 removed: 1,
918 total: 5,
919 };
920 assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
921 }
922
923 #[test]
924 fn test_observe_config_default() {
925 let config = ObserveConfig::default();
926 assert!(config.viewport_only);
927 }
928
929 #[test]
930 fn test_fingerprint_uses_full_selector() {
931 let base = "a".repeat(50);
933 let sel_a = format!("{}AAAA", base);
934 let sel_b = format!("{}BBBB", base);
935
936 let fp_a = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_a);
937 let fp_b = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_b);
938
939 assert_ne!(
940 fp_a, fp_b,
941 "selectors differing after char 50 should produce different fingerprints"
942 );
943 }
944}