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