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[..selector.len().min(50)].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 AgentPage<'a> {
179 page: &'a Page,
180 elements: Vec<InteractiveElement>,
181 config: ObserveConfig,
182}
183
184impl<'a> AgentPage<'a> {
185 pub fn new(page: &'a Page) -> Self {
187 Self {
188 page,
189 elements: Vec::new(),
190 config: ObserveConfig::default(),
191 }
192 }
193
194 pub fn with_config(page: &'a Page, config: ObserveConfig) -> Self {
196 Self {
197 page,
198 elements: Vec::new(),
199 config,
200 }
201 }
202
203 pub fn page(&self) -> &Page {
205 self.page
206 }
207
208 pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
214 self.elements = observe::observe(self.page, self.config.viewport_only).await?;
215 Ok(&self.elements)
216 }
217
218 pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
222 let old_selectors: HashSet<String> =
223 self.elements.iter().map(|e| e.selector.clone()).collect();
224
225 self.elements = observe::observe(self.page, self.config.viewport_only).await?;
226
227 let new_selectors: HashSet<&str> =
228 self.elements.iter().map(|e| e.selector.as_str()).collect();
229
230 let added: Vec<usize> = self
231 .elements
232 .iter()
233 .filter(|e| !old_selectors.contains(&e.selector))
234 .map(|e| e.index)
235 .collect();
236
237 let removed = old_selectors
238 .iter()
239 .filter(|s| !new_selectors.contains(s.as_str()))
240 .count();
241
242 Ok(ObserveDiff {
243 added,
244 removed,
245 total: self.elements.len(),
246 })
247 }
248
249 pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
251 let mut out = String::new();
252 for &idx in &diff.added {
253 if let Some(el) = self.elements.get(idx) {
254 out.push_str(&el.to_string());
255 out.push('\n');
256 }
257 }
258 out
259 }
260
261 pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
264 if self.elements.is_empty() {
265 self.observe().await?;
266 }
267 annotate::annotated_screenshot(self.page, &self.elements).await
268 }
269
270 pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
272 self.page.screenshot().await
273 }
274
275 pub fn element_list(&self) -> String {
278 let mut out = String::with_capacity(self.elements.len() * 40);
279 for el in &self.elements {
280 out.push_str(&el.to_string());
281 out.push('\n');
282 }
283 out
284 }
285
286 pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
288 self.elements.get(index)
289 }
290
291 pub fn elements(&self) -> &[InteractiveElement] {
293 &self.elements
294 }
295
296 pub fn len(&self) -> usize {
298 self.elements.len()
299 }
300
301 pub fn is_empty(&self) -> bool {
303 self.elements.is_empty()
304 }
305
306 pub fn find_by_text(&self, needle: &str) -> Option<usize> {
309 let needle_lower = needle.to_lowercase();
310 self.elements
311 .iter()
312 .find(|e| e.text.to_lowercase().contains(&needle_lower))
313 .map(|e| e.index)
314 }
315
316 pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
318 let needle_lower = needle.to_lowercase();
319 self.elements
320 .iter()
321 .filter(|e| e.text.to_lowercase().contains(&needle_lower))
322 .map(|e| e.index)
323 .collect()
324 }
325
326 pub async fn click(&self, index: usize) -> Result<()> {
332 let el = self.require(index)?;
333 self.page.click(&el.selector).await
334 }
335
336 pub async fn try_click(&self, index: usize) -> Result<bool> {
338 let el = self.require(index)?;
339 self.page.try_click(&el.selector).await
340 }
341
342 pub async fn human_click(&self, index: usize) -> Result<()> {
344 let el = self.require(index)?;
345 self.page.human_click(&el.selector).await
346 }
347
348 pub async fn fill(&self, index: usize, text: &str) -> Result<()> {
350 let el = self.require(index)?;
351 self.page.fill(&el.selector, text).await
352 }
353
354 pub async fn human_fill(&self, index: usize, text: &str) -> Result<()> {
356 let el = self.require(index)?;
357 self.page.human_fill(&el.selector, text).await
358 }
359
360 pub async fn focus(&self, index: usize) -> Result<()> {
362 let el = self.require(index)?;
363 self.page
364 .execute(&format!(
365 "document.querySelector({})?.focus()",
366 serde_json::to_string(&el.selector).unwrap()
367 ))
368 .await
369 }
370
371 pub async fn select(&self, index: usize, value: &str) -> Result<()> {
373 let el = self.require(index)?;
374 let arg = serde_json::json!({ "sel": el.selector, "val": value });
375 let js = format!(
376 r#"(() => {{
377 const arg = {arg};
378 const sel = document.querySelector(arg.sel);
379 if (!sel) return false;
380 const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
381 if (!opt) return false;
382 sel.value = opt.value;
383 sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
384 return true;
385 }})()"#,
386 arg = serde_json::to_string(&arg).unwrap()
387 );
388 let selected: bool = self.page.evaluate(&js).await?;
389 if !selected {
390 return Err(eoka::Error::ElementNotFound(format!(
391 "option \"{}\" in element [{}]",
392 value, index
393 )));
394 }
395 Ok(())
396 }
397
398 pub async fn options(&self, index: usize) -> Result<Vec<(String, String)>> {
400 let el = self.require(index)?;
401 let js = format!(
402 r#"(() => {{
403 const sel = document.querySelector({});
404 if (!sel || !sel.options) return '[]';
405 return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
406 }})()"#,
407 serde_json::to_string(&el.selector).unwrap()
408 );
409 let json_str: String = self.page.evaluate(&js).await?;
410 let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
411 .map_err(|e| eoka::Error::CdpSimple(format!("options parse error: {}", e)))?;
412 Ok(pairs)
413 }
414
415 pub async fn scroll_to(&self, index: usize) -> Result<()> {
417 let el = self.require(index)?;
418 let js = format!(
419 "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
420 serde_json::to_string(&el.selector).unwrap()
421 );
422 self.page.execute(&js).await
423 }
424
425 pub async fn goto(&mut self, url: &str) -> Result<()> {
431 self.elements.clear();
432 self.page.goto(url).await
433 }
434
435 pub async fn back(&mut self) -> Result<()> {
437 self.elements.clear();
438 self.page.back().await
439 }
440
441 pub async fn forward(&mut self) -> Result<()> {
443 self.elements.clear();
444 self.page.forward().await
445 }
446
447 pub async fn reload(&mut self) -> Result<()> {
449 self.elements.clear();
450 self.page.reload().await
451 }
452
453 pub async fn url(&self) -> Result<String> {
459 self.page.url().await
460 }
461
462 pub async fn title(&self) -> Result<String> {
464 self.page.title().await
465 }
466
467 pub async fn text(&self) -> Result<String> {
469 self.page.text().await
470 }
471
472 pub async fn scroll_down(&self) -> Result<()> {
478 self.page
479 .execute("window.scrollBy(0, window.innerHeight * 0.8)")
480 .await
481 }
482
483 pub async fn scroll_up(&self) -> Result<()> {
485 self.page
486 .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
487 .await
488 }
489
490 pub async fn scroll_to_top(&self) -> Result<()> {
492 self.page.execute("window.scrollTo(0, 0)").await
493 }
494
495 pub async fn scroll_to_bottom(&self) -> Result<()> {
497 self.page
498 .execute("window.scrollTo(0, document.body.scrollHeight)")
499 .await
500 }
501
502 pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
508 self.page.wait_for_text(text, timeout_ms).await?;
509 Ok(())
510 }
511
512 pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
514 self.page.wait_for_url_contains(pattern, timeout_ms).await
515 }
516
517 pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
519 self.page.wait_for_network_idle(500, timeout_ms).await
520 }
521
522 pub async fn wait(&self, ms: u64) {
524 self.page.wait(ms).await;
525 }
526
527 pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
533 self.page.evaluate(js).await
534 }
535
536 pub async fn exec(&self, js: &str) -> Result<()> {
538 self.page.execute(js).await
539 }
540
541 pub async fn press_key(&self, key: &str) -> Result<()> {
547 self.page.human().press_key(key).await
548 }
549
550 pub async fn submit(&self, index: usize) -> Result<()> {
552 self.focus(index).await?;
553 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
554 self.page.human().press_key("Enter").await
555 }
556
557 pub async fn hover(&self, index: usize) -> Result<()> {
563 let el = self.require(index)?;
564 let cx = el.bbox.x + el.bbox.width / 2.0;
565 let cy = el.bbox.y + el.bbox.height / 2.0;
566 self.page
567 .session()
568 .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
569 .await
570 }
571
572 pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
589 let escaped_js = serde_json::to_string(js_expression)
592 .map_err(|e| eoka::Error::CdpSimple(format!("Failed to escape JS: {}", e)))?;
593 let js = format!("JSON.stringify(eval({}))", escaped_js);
594 let json_str: String = self.page.evaluate(&js).await?;
595 if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
596 return Err(eoka::Error::CdpSimple(format!(
597 "extract returned null/undefined for: {}",
598 if js_expression.len() > 60 {
599 &js_expression[..60]
600 } else {
601 js_expression
602 }
603 )));
604 }
605 serde_json::from_str(&json_str).map_err(|e| {
606 eoka::Error::CdpSimple(format!(
607 "extract parse error: {} (got: {})",
608 e,
609 if json_str.len() > 80 {
610 &json_str[..80]
611 } else {
612 &json_str
613 }
614 ))
615 })
616 }
617
618 pub async fn wait_for_stable(&self) -> Result<()> {
626 let _ = self.page.wait_for_network_idle(200, 2000).await;
628 self.page.wait(50).await;
630 Ok(())
631 }
632
633 pub async fn click_and_wait(&mut self, index: usize) -> Result<()> {
635 self.click(index).await?;
636 self.wait_for_stable().await?;
637 self.elements.clear();
639 Ok(())
640 }
641
642 pub async fn fill_and_wait(&mut self, index: usize, text: &str) -> Result<()> {
644 self.fill(index, text).await?;
645 self.wait_for_stable().await?;
646 Ok(())
647 }
648
649 pub async fn select_and_wait(&mut self, index: usize, value: &str) -> Result<()> {
651 self.select(index, value).await?;
652 self.wait_for_stable().await?;
653 Ok(())
654 }
655
656 pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
662 spa::detect_router(self.page).await
663 }
664
665 pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
669 let info = spa::detect_router(self.page).await?;
670 let result = spa::spa_navigate(self.page, &info.router_type, path).await?;
671 self.elements.clear();
672 Ok(result)
673 }
674
675 pub async fn history_go(&mut self, delta: i32) -> Result<()> {
679 spa::history_go(self.page, delta).await?;
680 self.elements.clear();
681 Ok(())
682 }
683
684 fn require(&self, index: usize) -> Result<&InteractiveElement> {
689 self.elements.get(index).ok_or_else(|| {
690 eoka::Error::ElementNotFound(format!(
691 "element [{}] (observed {} elements — call observe() to refresh)",
692 index,
693 self.elements.len()
694 ))
695 })
696 }
697}
698
699pub struct Session {
706 browser: Browser,
707 page: Page,
708 elements: Vec<InteractiveElement>,
709 config: ObserveConfig,
710}
711
712impl Session {
713 pub async fn launch() -> Result<Self> {
715 let browser = Browser::launch().await?;
716 let page = browser.new_page("about:blank").await?;
717 Ok(Self {
718 browser,
719 page,
720 elements: Vec::new(),
721 config: ObserveConfig::default(),
722 })
723 }
724
725 pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
727 let browser = Browser::launch_with_config(stealth).await?;
728 let page = browser.new_page("about:blank").await?;
729 Ok(Self {
730 browser,
731 page,
732 elements: Vec::new(),
733 config: ObserveConfig::default(),
734 })
735 }
736
737 pub fn set_observe_config(&mut self, config: ObserveConfig) {
739 self.config = config;
740 }
741
742 pub fn page(&self) -> &Page {
744 &self.page
745 }
746
747 pub fn browser(&self) -> &Browser {
749 &self.browser
750 }
751
752 pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
758 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
759 Ok(&self.elements)
760 }
761
762 pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
764 if self.elements.is_empty() {
765 self.observe().await?;
766 }
767 annotate::annotated_screenshot(&self.page, &self.elements).await
768 }
769
770 pub fn element_list(&self) -> String {
772 let mut out = String::with_capacity(self.elements.len() * 40);
773 for el in &self.elements {
774 out.push_str(&el.to_string());
775 out.push('\n');
776 }
777 out
778 }
779
780 pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
782 self.elements.get(index)
783 }
784
785 pub fn elements(&self) -> &[InteractiveElement] {
787 &self.elements
788 }
789
790 pub fn len(&self) -> usize {
792 self.elements.len()
793 }
794
795 pub fn is_empty(&self) -> bool {
797 self.elements.is_empty()
798 }
799
800 pub fn find_by_text(&self, needle: &str) -> Option<usize> {
802 let needle_lower = needle.to_lowercase();
803 self.elements
804 .iter()
805 .find(|e| e.text.to_lowercase().contains(&needle_lower))
806 .map(|e| e.index)
807 }
808
809 async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
816 let stored = self.elements.get(index).cloned();
818
819 if let Some(ref el) = stored {
820 let js = format!(
822 "!!document.querySelector({})",
823 serde_json::to_string(&el.selector).unwrap()
824 );
825 let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
826
827 if exists {
828 return self.elements.get(index).ok_or_else(|| {
829 eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
830 });
831 }
832
833 self.observe().await?;
835
836 if let Some(new_idx) = self
838 .elements
839 .iter()
840 .position(|e| e.fingerprint == el.fingerprint)
841 {
842 return Err(eoka::Error::ElementNotFound(format!(
844 "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
845 index, el.text, new_idx
846 )));
847 }
848
849 return Err(eoka::Error::ElementNotFound(format!(
850 "element [{}] \"{}\" no longer exists on page",
851 index, el.text
852 )));
853 }
854
855 Err(eoka::Error::ElementNotFound(format!(
856 "element [{}] not found (observed {} elements)",
857 index,
858 self.elements.len()
859 )))
860 }
861
862 pub async fn click(&mut self, index: usize) -> Result<()> {
865 let el = self.require_fresh(index).await?;
866 let selector = el.selector.clone();
867 self.page.click(&selector).await?;
868 self.wait_for_stable().await?;
869 self.elements.clear(); Ok(())
871 }
872
873 pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
876 let el = self.require_fresh(index).await?;
877 let selector = el.selector.clone();
878 self.page.fill(&selector, text).await?;
879 self.wait_for_stable().await?;
880 Ok(())
881 }
882
883 pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
886 let el = self.require_fresh(index).await?;
887 let selector = el.selector.clone();
888 let arg = serde_json::json!({ "sel": selector, "val": value });
889 let js = format!(
890 r#"(() => {{
891 const arg = {arg};
892 const sel = document.querySelector(arg.sel);
893 if (!sel) return false;
894 const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
895 if (!opt) return false;
896 sel.value = opt.value;
897 sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
898 return true;
899 }})()"#,
900 arg = serde_json::to_string(&arg).unwrap()
901 );
902 let selected: bool = self.page.evaluate(&js).await?;
903 if !selected {
904 return Err(eoka::Error::ElementNotFound(format!(
905 "option \"{}\" in element [{}]",
906 value, index
907 )));
908 }
909 self.wait_for_stable().await?;
910 self.elements.clear(); Ok(())
912 }
913
914 pub async fn hover(&mut self, index: usize) -> Result<()> {
916 let el = self.require_fresh(index).await?;
917 let cx = el.bbox.x + el.bbox.width / 2.0;
918 let cy = el.bbox.y + el.bbox.height / 2.0;
919 self.page
920 .session()
921 .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
922 .await
923 }
924
925 pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
927 let el = self.require_fresh(index).await?;
928 let selector = el.selector.clone();
929 let js = format!(
930 "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
931 serde_json::to_string(&selector).unwrap()
932 );
933 self.page.execute(&js).await
934 }
935
936 pub async fn goto(&mut self, url: &str) -> Result<()> {
942 self.elements.clear();
943 self.page.goto(url).await?;
944 self.wait_for_stable().await
945 }
946
947 pub async fn back(&mut self) -> Result<()> {
949 self.elements.clear();
950 self.page.back().await?;
951 self.wait_for_stable().await
952 }
953
954 pub async fn forward(&mut self) -> Result<()> {
956 self.elements.clear();
957 self.page.forward().await?;
958 self.wait_for_stable().await
959 }
960
961 pub async fn url(&self) -> Result<String> {
967 self.page.url().await
968 }
969
970 pub async fn title(&self) -> Result<String> {
972 self.page.title().await
973 }
974
975 pub async fn text(&self) -> Result<String> {
977 self.page.text().await
978 }
979
980 pub async fn scroll_down(&self) -> Result<()> {
986 self.page
987 .execute("window.scrollBy(0, window.innerHeight * 0.8)")
988 .await
989 }
990
991 pub async fn scroll_up(&self) -> Result<()> {
993 self.page
994 .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
995 .await
996 }
997
998 pub async fn scroll_to_top(&self) -> Result<()> {
1000 self.page.execute("window.scrollTo(0, 0)").await
1001 }
1002
1003 pub async fn scroll_to_bottom(&self) -> Result<()> {
1005 self.page
1006 .execute("window.scrollTo(0, document.body.scrollHeight)")
1007 .await
1008 }
1009
1010 pub async fn wait_for_stable(&self) -> Result<()> {
1018 let _ = self.page.wait_for_network_idle(200, 2000).await;
1020 self.page.wait(50).await;
1022 Ok(())
1023 }
1024
1025 pub async fn wait(&self, ms: u64) {
1027 self.page.wait(ms).await;
1028 }
1029
1030 pub async fn press_key(&self, key: &str) -> Result<()> {
1036 self.page.human().press_key(key).await
1037 }
1038
1039 pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
1045 self.page.evaluate(js).await
1046 }
1047
1048 pub async fn exec(&self, js: &str) -> Result<()> {
1050 self.page.execute(js).await
1051 }
1052
1053 pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
1059 spa::detect_router(&self.page).await
1060 }
1061
1062 pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
1066 let info = spa::detect_router(&self.page).await?;
1067 let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
1068 self.elements.clear();
1069 Ok(result)
1070 }
1071
1072 pub async fn history_go(&mut self, delta: i32) -> Result<()> {
1076 spa::history_go(&self.page, delta).await?;
1077 self.elements.clear();
1078 Ok(())
1079 }
1080
1081 pub async fn close(self) -> Result<()> {
1087 self.browser.close().await
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 fn make_element(
1096 index: usize,
1097 tag: &str,
1098 text: &str,
1099 role: Option<&str>,
1100 input_type: Option<&str>,
1101 placeholder: Option<&str>,
1102 value: Option<&str>,
1103 checked: bool,
1104 ) -> InteractiveElement {
1105 let selector = format!("[data-idx=\"{}\"]", index);
1106 let fingerprint = InteractiveElement::compute_fingerprint(
1107 tag,
1108 text,
1109 role,
1110 input_type,
1111 placeholder,
1112 &selector,
1113 );
1114 InteractiveElement {
1115 index,
1116 tag: tag.to_string(),
1117 text: text.to_string(),
1118 role: role.map(|s| s.to_string()),
1119 input_type: input_type.map(|s| s.to_string()),
1120 placeholder: placeholder.map(|s| s.to_string()),
1121 value: value.map(|s| s.to_string()),
1122 checked,
1123 selector,
1124 bbox: BoundingBox {
1125 x: 0.0,
1126 y: 0.0,
1127 width: 100.0,
1128 height: 30.0,
1129 },
1130 fingerprint,
1131 }
1132 }
1133
1134 #[test]
1135 fn test_element_display_basic() {
1136 let el = make_element(0, "button", "Submit", None, None, None, None, false);
1137 assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
1138 }
1139
1140 #[test]
1141 fn test_element_display_with_input_type() {
1142 let el = make_element(0, "input", "", None, Some("text"), None, None, false);
1144 assert_eq!(el.to_string(), "[0] <input>");
1145
1146 let el = make_element(0, "input", "", None, Some("password"), None, None, false);
1148 assert_eq!(el.to_string(), "[0] <input type=\"password\">");
1149 }
1150
1151 #[test]
1152 fn test_element_display_with_placeholder() {
1153 let el = make_element(
1154 0,
1155 "input",
1156 "",
1157 None,
1158 Some("text"),
1159 Some("Enter email"),
1160 None,
1161 false,
1162 );
1163 assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
1164 }
1165
1166 #[test]
1167 fn test_element_display_with_value() {
1168 let el = make_element(
1169 0,
1170 "input",
1171 "",
1172 None,
1173 Some("text"),
1174 None,
1175 Some("hello"),
1176 false,
1177 );
1178 assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
1179 }
1180
1181 #[test]
1182 fn test_element_display_checked() {
1183 let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
1184 assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
1185 }
1186
1187 #[test]
1188 fn test_element_display_redundant_role_suppressed() {
1189 let el = make_element(
1191 0,
1192 "button",
1193 "Click",
1194 Some("button"),
1195 None,
1196 None,
1197 None,
1198 false,
1199 );
1200 assert_eq!(el.to_string(), "[0] <button> \"Click\"");
1201
1202 let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
1204 assert_eq!(el.to_string(), "[0] <a> \"Link\"");
1205
1206 let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
1208 assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
1209 }
1210
1211 #[test]
1212 fn test_element_display_non_redundant_role_shown() {
1213 let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
1215 assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
1216
1217 let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
1219 assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
1220 }
1221
1222 #[test]
1223 fn test_observe_diff_display_no_changes() {
1224 let diff = ObserveDiff {
1225 added: vec![],
1226 removed: 0,
1227 total: 5,
1228 };
1229 assert_eq!(diff.to_string(), "no changes (5 elements)");
1230 }
1231
1232 #[test]
1233 fn test_observe_diff_display_added_only() {
1234 let diff = ObserveDiff {
1235 added: vec![5, 6],
1236 removed: 0,
1237 total: 7,
1238 };
1239 assert_eq!(diff.to_string(), "+2 added (7 total)");
1240 }
1241
1242 #[test]
1243 fn test_observe_diff_display_removed_only() {
1244 let diff = ObserveDiff {
1245 added: vec![],
1246 removed: 3,
1247 total: 2,
1248 };
1249 assert_eq!(diff.to_string(), "-3 removed (2 total)");
1250 }
1251
1252 #[test]
1253 fn test_observe_diff_display_both() {
1254 let diff = ObserveDiff {
1255 added: vec![3, 4],
1256 removed: 1,
1257 total: 5,
1258 };
1259 assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
1260 }
1261
1262 #[test]
1263 fn test_observe_config_default() {
1264 let config = ObserveConfig::default();
1265 assert!(config.viewport_only);
1266 }
1267}