Skip to main content

eoka_server/
browser.rs

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}