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, 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}