1use crate::{annotate, observe, snapshot, spa};
2
3pub use crate::spa::{RouterType, SpaRouterInfo};
4pub use crate::target::{BBox, LivePattern, Resolved, Target};
5
6use std::collections::HashSet;
7use std::fmt;
8
9use crate::eoka::{BoundingBox, MouseButton, Page, Result};
10
11pub use crate::eoka::{Browser, Error, StealthConfig};
12
13#[derive(Debug, Clone)]
14pub struct InteractiveElement {
15 pub index: usize,
16 pub tag: String,
17 pub role: Option<String>,
18 pub text: String,
19 pub placeholder: Option<String>,
20 pub input_type: Option<String>,
21 pub selector: String,
22 pub checked: bool,
23 pub disabled: bool,
24 pub visible: bool,
25 pub value: Option<String>,
26 pub bbox: BoundingBox,
27 pub fingerprint: u64,
28}
29
30impl InteractiveElement {
31 pub fn compute_fingerprint(
32 tag: &str,
33 text: &str,
34 role: Option<&str>,
35 input_type: Option<&str>,
36 placeholder: Option<&str>,
37 selector: &str,
38 ) -> u64 {
39 use std::collections::hash_map::DefaultHasher;
40 use std::hash::{Hash, Hasher};
41 let mut hasher = DefaultHasher::new();
42 tag.hash(&mut hasher);
43 text.hash(&mut hasher);
44 role.hash(&mut hasher);
45 input_type.hash(&mut hasher);
46 placeholder.hash(&mut hasher);
47 selector.hash(&mut hasher);
48 hasher.finish()
49 }
50}
51
52impl fmt::Display for InteractiveElement {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "[{}] <{}", self.index, self.tag)?;
55 if let Some(ref t) = self.input_type {
56 if t != "text" {
57 write!(f, " type=\"{}\"", t)?;
58 }
59 }
60 f.write_str(">")?;
61 if self.checked {
62 f.write_str(" [checked]")?;
63 }
64 if !self.text.is_empty() {
65 write!(f, " \"{}\"", self.text)?;
66 }
67 if let Some(ref v) = self.value {
68 write!(f, " value=\"{}\"", v)?;
69 }
70 if let Some(ref p) = self.placeholder {
71 write!(f, " placeholder=\"{}\"", p)?;
72 }
73 if let Some(ref r) = self.role {
74 let redundant = (r == "button" && self.tag == "button")
75 || (r == "link" && self.tag == "a")
76 || (r == "menuitem" && self.tag == "a");
77 if !redundant {
78 write!(f, " role=\"{}\"", r)?;
79 }
80 }
81 Ok(())
82 }
83}
84
85#[derive(Debug, Clone)]
86pub struct ObserveConfig {
87 pub viewport_only: bool,
88}
89
90impl Default for ObserveConfig {
91 fn default() -> Self {
92 Self {
93 viewport_only: true,
94 }
95 }
96}
97
98#[derive(Debug)]
99pub struct ObserveDiff {
100 pub added: Vec<usize>,
101 pub removed: usize,
102 pub total: usize,
103}
104
105impl fmt::Display for ObserveDiff {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 if self.added.is_empty() && self.removed == 0 {
108 write!(f, "no changes ({} elements)", self.total)
109 } else {
110 let mut need_sep = false;
111 if !self.added.is_empty() {
112 write!(f, "+{} added", self.added.len())?;
113 need_sep = true;
114 }
115 if self.removed > 0 {
116 if need_sep {
117 write!(f, ", ")?;
118 }
119 write!(f, "-{} removed", self.removed)?;
120 }
121 write!(f, " ({} total)", self.total)
122 }
123 }
124}
125
126pub struct Session {
127 browser: Browser,
128 page: Page,
129 elements: Vec<InteractiveElement>,
130 config: ObserveConfig,
131}
132
133impl Session {
134 pub async fn launch() -> Result<Self> {
135 let browser = Browser::launch().await?;
136 let page = browser.new_page("about:blank").await?;
137 Ok(Self {
138 browser,
139 page,
140 elements: Vec::new(),
141 config: ObserveConfig::default(),
142 })
143 }
144
145 pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
146 let browser = Browser::launch_with_config(stealth).await?;
147 let page = browser.new_page("about:blank").await?;
148 Ok(Self {
149 browser,
150 page,
151 elements: Vec::new(),
152 config: ObserveConfig::default(),
153 })
154 }
155
156 pub fn set_observe_config(&mut self, config: ObserveConfig) {
157 self.config = config;
158 }
159
160 pub fn page(&self) -> &Page {
161 &self.page
162 }
163
164 pub fn browser(&self) -> &Browser {
165 &self.browser
166 }
167
168 pub async fn ax_snapshot(&self, include_all: bool) -> anyhow::Result<snapshot::SnapshotResult> {
169 snapshot::snapshot(&self.page, include_all).await
170 }
171
172 pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
173 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
174 Ok(&self.elements)
175 }
176
177 pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
178 if self.elements.is_empty() {
179 self.observe().await?;
180 }
181 annotate::annotated_screenshot(&self.page, &self.elements).await
182 }
183
184 pub fn element_list(&self) -> String {
185 let mut out = String::with_capacity(self.elements.len() * 40);
186 for el in &self.elements {
187 out.push_str(&el.to_string());
188 out.push('\n');
189 }
190 out
191 }
192
193 pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
194 self.elements.get(index)
195 }
196
197 pub fn elements(&self) -> &[InteractiveElement] {
198 &self.elements
199 }
200
201 pub fn len(&self) -> usize {
202 self.elements.len()
203 }
204
205 pub fn is_empty(&self) -> bool {
206 self.elements.is_empty()
207 }
208
209 pub fn find_by_text(&self, needle: &str) -> Option<usize> {
210 let needle_lower = needle.to_lowercase();
211 self.elements
212 .iter()
213 .find(|e| e.text.to_lowercase().contains(&needle_lower))
214 .map(|e| e.index)
215 }
216
217 pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
218 let needle_lower = needle.to_lowercase();
219 self.elements
220 .iter()
221 .filter(|e| e.text.to_lowercase().contains(&needle_lower))
222 .map(|e| e.index)
223 .collect()
224 }
225
226 pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
227 let old_selectors: HashSet<String> =
228 self.elements.iter().map(|e| e.selector.clone()).collect();
229
230 self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
231
232 let new_selectors: HashSet<&str> =
233 self.elements.iter().map(|e| e.selector.as_str()).collect();
234
235 let added: Vec<usize> = self
236 .elements
237 .iter()
238 .filter(|e| !old_selectors.contains(&e.selector))
239 .map(|e| e.index)
240 .collect();
241
242 let removed = old_selectors
243 .iter()
244 .filter(|s| !new_selectors.contains(s.as_str()))
245 .count();
246
247 Ok(ObserveDiff {
248 added,
249 removed,
250 total: self.elements.len(),
251 })
252 }
253
254 pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
255 let mut out = String::new();
256 for &idx in &diff.added {
257 if let Some(el) = self.elements.get(idx) {
258 out.push_str(&el.to_string());
259 out.push('\n');
260 }
261 }
262 out
263 }
264
265 pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
266 self.page.screenshot().await
267 }
268
269 async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
270 let stored = self.elements.get(index).cloned();
271
272 if let Some(ref el) = stored {
273 let js = format!(
274 "!!document.querySelector({})",
275 serde_json::to_string(&el.selector).unwrap()
276 );
277 let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
278
279 if exists {
280 return self.elements.get(index).ok_or_else(|| {
281 crate::eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
282 });
283 }
284
285 self.observe().await?;
286
287 if let Some(new_idx) = self
288 .elements
289 .iter()
290 .position(|e| e.fingerprint == el.fingerprint)
291 {
292 return Err(crate::eoka::Error::ElementNotFound(format!(
293 "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
294 index, el.text, new_idx
295 )));
296 }
297
298 return Err(crate::eoka::Error::ElementNotFound(format!(
299 "element [{}] \"{}\" no longer exists on page",
300 index, el.text
301 )));
302 }
303
304 Err(crate::eoka::Error::ElementNotFound(format!(
305 "element [{}] not found (observed {} elements)",
306 index,
307 self.elements.len()
308 )))
309 }
310
311 pub async fn click(&mut self, index: usize) -> Result<()> {
312 let el = self.require_fresh(index).await?;
313 let selector = el.selector.clone();
314 self.page.click(&selector).await?;
315 self.wait_for_stable().await?;
316 self.elements.clear();
317 Ok(())
318 }
319
320 pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
321 let el = self.require_fresh(index).await?;
322 let selector = el.selector.clone();
323 self.page.fill(&selector, text).await?;
324 self.wait_for_stable().await?;
325 Ok(())
326 }
327
328 pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
329 let el = self.require_fresh(index).await?;
330 let selector = el.selector.clone();
331 let arg = serde_json::json!({ "sel": selector, "val": value });
332 let js = format!(
333 r#"(() => {{
334 const arg = {arg};
335 const sel = document.querySelector(arg.sel);
336 if (!sel) return false;
337 const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
338 if (!opt) return false;
339 sel.value = opt.value;
340 sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
341 return true;
342 }})()"#,
343 arg = serde_json::to_string(&arg).unwrap()
344 );
345 let selected: bool = self.page.evaluate(&js).await?;
346 if !selected {
347 return Err(crate::eoka::Error::ElementNotFound(format!(
348 "option \"{}\" in element [{}]",
349 value, index
350 )));
351 }
352 self.wait_for_stable().await?;
353 self.elements.clear();
354 Ok(())
355 }
356
357 pub async fn hover(&mut self, index: usize) -> Result<()> {
358 let selector = self.require_fresh(index).await?.selector.clone();
359 self.page.hover(&selector).await
360 }
361
362 pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
363 let el = self.require_fresh(index).await?;
364 let selector = el.selector.clone();
365 let js = format!(
366 "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
367 serde_json::to_string(&selector).unwrap()
368 );
369 self.page.execute(&js).await
370 }
371
372 pub async fn try_click(&mut self, index: usize) -> Result<bool> {
373 let el = self.require_fresh(index).await?;
374 let selector = el.selector.clone();
375 self.page.try_click(&selector).await
376 }
377
378 pub async fn human_click(&mut self, index: usize) -> Result<()> {
379 let el = self.require_fresh(index).await?;
380 let selector = el.selector.clone();
381 self.page.human_click(&selector).await
382 }
383
384 pub async fn human_fill(&mut self, index: usize, text: &str) -> Result<()> {
385 let el = self.require_fresh(index).await?;
386 let selector = el.selector.clone();
387 self.page.human_fill(&selector, text).await
388 }
389
390 pub async fn focus(&mut self, index: usize) -> Result<()> {
391 let el = self.require_fresh(index).await?;
392 let selector = el.selector.clone();
393 self.page
394 .execute(&format!(
395 "document.querySelector({})?.focus()",
396 serde_json::to_string(&selector).unwrap()
397 ))
398 .await
399 }
400
401 pub async fn submit(&mut self, index: usize) -> Result<()> {
402 self.focus(index).await?;
403 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
404 self.page.human().press_key("Enter").await
405 }
406
407 pub async fn options(&mut self, index: usize) -> Result<Vec<(String, String)>> {
408 let el = self.require_fresh(index).await?;
409 let selector = el.selector.clone();
410 let js = format!(
411 r#"(() => {{
412 const sel = document.querySelector({});
413 if (!sel || !sel.options) return '[]';
414 return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
415 }})()"#,
416 serde_json::to_string(&selector).unwrap()
417 );
418 let json_str: String = self.page.evaluate(&js).await?;
419 let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
420 .map_err(|e| crate::eoka::Error::cdp_msg(format!("options parse error: {}", e)))?;
421 Ok(pairs)
422 }
423
424 pub async fn goto(&mut self, url: &str) -> Result<()> {
425 self.elements.clear();
426 self.page.goto(url).await?;
427 self.wait_for_stable().await
428 }
429
430 pub async fn back(&mut self) -> Result<()> {
431 self.elements.clear();
432 self.page.back().await?;
433 self.wait_for_stable().await
434 }
435
436 pub async fn forward(&mut self) -> Result<()> {
437 self.elements.clear();
438 self.page.forward().await?;
439 self.wait_for_stable().await
440 }
441
442 pub async fn reload(&mut self) -> Result<()> {
443 self.elements.clear();
444 self.page.reload().await?;
445 self.wait_for_stable().await
446 }
447
448 pub async fn url(&self) -> Result<String> {
449 self.page.url().await
450 }
451
452 pub async fn title(&self) -> Result<String> {
453 self.page.title().await
454 }
455
456 pub async fn text(&self) -> Result<String> {
457 self.page.text().await
458 }
459
460 pub async fn scroll_down(&self) -> Result<()> {
461 self.page
462 .execute("window.scrollBy(0, window.innerHeight * 0.8)")
463 .await
464 }
465
466 pub async fn scroll_up(&self) -> Result<()> {
467 self.page
468 .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
469 .await
470 }
471
472 pub async fn scroll_to_top(&self) -> Result<()> {
473 self.page.execute("window.scrollTo(0, 0)").await
474 }
475
476 pub async fn scroll_to_bottom(&self) -> Result<()> {
477 self.page
478 .execute("window.scrollTo(0, document.body.scrollHeight)")
479 .await
480 }
481
482 pub async fn wait_for_stable(&self) -> Result<()> {
483 let _ = self.page.wait_for_network_idle(200, 2000).await;
484 self.page.wait(50).await;
485 Ok(())
486 }
487
488 pub async fn wait(&self, ms: u64) {
489 self.page.wait(ms).await;
490 }
491
492 pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
493 self.page.wait_for_text(text, timeout_ms).await?;
494 Ok(())
495 }
496
497 pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
498 self.page.wait_for_url_contains(pattern, timeout_ms).await
499 }
500
501 pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
502 self.page.wait_for_network_idle(500, timeout_ms).await
503 }
504
505 pub async fn press_key(&self, key: &str) -> Result<()> {
506 self.page.human().press_key(key).await
507 }
508
509 pub async fn mouse_down(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
510 self.page.mouse_down(x, y, button).await
511 }
512
513 pub async fn mouse_move(&self, x: f64, y: f64) -> Result<()> {
514 self.page.mouse_move(x, y).await
515 }
516
517 pub async fn mouse_up(&self, x: f64, y: f64, button: MouseButton) -> Result<()> {
518 self.page.mouse_up(x, y, button).await
519 }
520
521 pub async fn key_down(&self, key: &str) -> Result<()> {
522 self.page.key_down(key).await
523 }
524
525 pub async fn key_up(&self, key: &str) -> Result<()> {
526 self.page.key_up(key).await
527 }
528
529 pub async fn release_all_inputs(&self) -> Result<()> {
530 self.page.release_all_inputs().await
531 }
532
533 pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
534 self.page.evaluate(js).await
535 }
536
537 pub async fn exec(&self, js: &str) -> Result<()> {
538 self.page.execute(js).await
539 }
540
541 pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
542 let escaped_js = serde_json::to_string(js_expression)
543 .map_err(|e| crate::eoka::Error::cdp_msg(format!("Failed to escape JS: {}", e)))?;
544 let js = format!("JSON.stringify(eval({}))", escaped_js);
545 let json_str: String = self.page.evaluate(&js).await?;
546 if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
547 return Err(crate::eoka::Error::cdp_msg(format!(
548 "extract returned null/undefined for: {}",
549 if js_expression.len() > 60 {
550 &js_expression[..60]
551 } else {
552 js_expression
553 }
554 )));
555 }
556 serde_json::from_str(&json_str).map_err(|e| {
557 crate::eoka::Error::cdp_msg(format!(
558 "extract parse error: {} (got: {})",
559 e,
560 if json_str.len() > 80 {
561 &json_str[..80]
562 } else {
563 &json_str
564 }
565 ))
566 })
567 }
568
569 pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
570 spa::detect_router(&self.page).await
571 }
572
573 pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
574 let info = spa::detect_router(&self.page).await?;
575 let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
576 self.elements.clear();
577 Ok(result)
578 }
579
580 pub async fn history_go(&mut self, delta: i32) -> Result<()> {
581 spa::history_go(&self.page, delta).await?;
582 self.elements.clear();
583 Ok(())
584 }
585
586 pub async fn close(self) -> Result<()> {
587 let release_result = self.page.release_all_inputs().await;
588 self.browser.close().await?;
589 release_result
590 }
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 #[allow(clippy::too_many_arguments)]
598 fn make_element(
599 index: usize,
600 tag: &str,
601 text: &str,
602 role: Option<&str>,
603 input_type: Option<&str>,
604 placeholder: Option<&str>,
605 value: Option<&str>,
606 checked: bool,
607 ) -> InteractiveElement {
608 let selector = format!("[data-idx=\"{}\"]", index);
609 let fingerprint = InteractiveElement::compute_fingerprint(
610 tag,
611 text,
612 role,
613 input_type,
614 placeholder,
615 &selector,
616 );
617 InteractiveElement {
618 index,
619 tag: tag.to_string(),
620 text: text.to_string(),
621 role: role.map(|s| s.to_string()),
622 input_type: input_type.map(|s| s.to_string()),
623 placeholder: placeholder.map(|s| s.to_string()),
624 value: value.map(|s| s.to_string()),
625 checked,
626 selector,
627 disabled: false,
628 visible: true,
629 bbox: BoundingBox {
630 x: 0.0,
631 y: 0.0,
632 width: 100.0,
633 height: 30.0,
634 },
635 fingerprint,
636 }
637 }
638
639 #[test]
640 fn test_element_display_basic() {
641 let el = make_element(0, "button", "Submit", None, None, None, None, false);
642 assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
643 }
644
645 #[test]
646 fn test_element_display_with_input_type() {
647 let el = make_element(0, "input", "", None, Some("text"), None, None, false);
648 assert_eq!(el.to_string(), "[0] <input>");
649
650 let el = make_element(0, "input", "", None, Some("password"), None, None, false);
651 assert_eq!(el.to_string(), "[0] <input type=\"password\">");
652 }
653
654 #[test]
655 fn test_element_display_with_placeholder() {
656 let el = make_element(
657 0,
658 "input",
659 "",
660 None,
661 Some("text"),
662 Some("Enter email"),
663 None,
664 false,
665 );
666 assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
667 }
668
669 #[test]
670 fn test_element_display_with_value() {
671 let el = make_element(
672 0,
673 "input",
674 "",
675 None,
676 Some("text"),
677 None,
678 Some("hello"),
679 false,
680 );
681 assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
682 }
683
684 #[test]
685 fn test_element_display_checked() {
686 let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
687 assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
688 }
689
690 #[test]
691 fn test_element_display_redundant_role_suppressed() {
692 let el = make_element(
693 0,
694 "button",
695 "Click",
696 Some("button"),
697 None,
698 None,
699 None,
700 false,
701 );
702 assert_eq!(el.to_string(), "[0] <button> \"Click\"");
703
704 let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
705 assert_eq!(el.to_string(), "[0] <a> \"Link\"");
706
707 let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
708 assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
709 }
710
711 #[test]
712 fn test_element_display_non_redundant_role_shown() {
713 let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
714 assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
715
716 let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
717 assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
718 }
719
720 #[test]
721 fn test_observe_diff_display_no_changes() {
722 let diff = ObserveDiff {
723 added: vec![],
724 removed: 0,
725 total: 5,
726 };
727 assert_eq!(diff.to_string(), "no changes (5 elements)");
728 }
729
730 #[test]
731 fn test_observe_diff_display_added_only() {
732 let diff = ObserveDiff {
733 added: vec![5, 6],
734 removed: 0,
735 total: 7,
736 };
737 assert_eq!(diff.to_string(), "+2 added (7 total)");
738 }
739
740 #[test]
741 fn test_observe_diff_display_removed_only() {
742 let diff = ObserveDiff {
743 added: vec![],
744 removed: 3,
745 total: 2,
746 };
747 assert_eq!(diff.to_string(), "-3 removed (2 total)");
748 }
749
750 #[test]
751 fn test_observe_diff_display_both() {
752 let diff = ObserveDiff {
753 added: vec![3, 4],
754 removed: 1,
755 total: 5,
756 };
757 assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
758 }
759
760 #[test]
761 fn test_observe_config_default() {
762 let config = ObserveConfig::default();
763 assert!(config.viewport_only);
764 }
765
766 #[test]
767 fn test_fingerprint_uses_full_selector() {
768 let base = "a".repeat(50);
769 let sel_a = format!("{}AAAA", base);
770 let sel_b = format!("{}BBBB", base);
771
772 let fp_a = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_a);
773 let fp_b = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_b);
774
775 assert_ne!(
776 fp_a, fp_b,
777 "selectors differing after char 50 should produce different fingerprints"
778 );
779 }
780}