Skip to main content

eoka_agent/
lib.rs

1//! # eoka-agent
2//!
3//! AI agent interaction layer for browser automation. Use directly or via MCP.
4//!
5//! ## Quick Start
6//!
7//! ```rust,no_run
8//! use eoka_agent::Session;
9//!
10//! # #[tokio::main]
11//! # async fn main() -> eoka::Result<()> {
12//! let mut session = Session::launch().await?;
13//! session.goto("https://example.com").await?;
14//!
15//! // Observe → get compact element list → act by index
16//! session.observe().await?;
17//! println!("{}", session.element_list());
18//! session.click(0).await?;
19//!
20//! session.close().await?;
21//! # Ok(())
22//! # }
23//! ```
24
25pub mod annotate;
26pub mod captcha;
27pub mod observe;
28pub mod spa;
29pub mod target;
30
31pub use spa::{RouterType, SpaRouterInfo};
32pub use target::{BBox, LivePattern, Resolved, Target};
33
34use std::collections::HashSet;
35use std::fmt;
36
37use eoka::{BoundingBox, Page, Result};
38
39// Re-export eoka types that users need
40pub use eoka::{Browser, Error, StealthConfig};
41
42/// An interactive element on the page, identified by index.
43#[derive(Debug, Clone)]
44pub struct InteractiveElement {
45    /// Zero-based index (stable until next `observe()`)
46    pub index: usize,
47    /// HTML tag name (e.g. "button", "input", "a")
48    pub tag: String,
49    /// ARIA role if set
50    pub role: Option<String>,
51    /// Visible text content, truncated to 60 chars
52    pub text: String,
53    /// Placeholder attribute for inputs
54    pub placeholder: Option<String>,
55    /// Input type (only for `<input>` and `<select>` elements)
56    pub input_type: Option<String>,
57    /// Unique CSS selector for this element
58    pub selector: String,
59    /// Whether the element is checked (radio/checkbox)
60    pub checked: bool,
61    /// Current value of form element (None if empty or non-form)
62    pub value: Option<String>,
63    /// Bounding box in viewport coordinates
64    pub bbox: BoundingBox,
65    /// Fingerprint for stale element detection (hash of tag+text+attributes)
66    pub fingerprint: u64,
67}
68
69impl InteractiveElement {
70    /// Create a fingerprint from element properties for stale detection.
71    /// Includes enough fields to distinguish similar elements.
72    pub fn compute_fingerprint(
73        tag: &str,
74        text: &str,
75        role: Option<&str>,
76        input_type: Option<&str>,
77        placeholder: Option<&str>,
78        selector: &str,
79    ) -> u64 {
80        use std::collections::hash_map::DefaultHasher;
81        use std::hash::{Hash, Hasher};
82        let mut hasher = DefaultHasher::new();
83        tag.hash(&mut hasher);
84        text.hash(&mut hasher);
85        role.hash(&mut hasher);
86        input_type.hash(&mut hasher);
87        placeholder.hash(&mut hasher);
88        // Include full selector for positional uniqueness
89        selector.hash(&mut hasher);
90        hasher.finish()
91    }
92}
93
94impl fmt::Display for InteractiveElement {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "[{}] <{}", self.index, self.tag)?;
97        if let Some(ref t) = self.input_type {
98            if t != "text" {
99                write!(f, " type=\"{}\"", t)?;
100            }
101        }
102        f.write_str(">")?;
103        if self.checked {
104            f.write_str(" [checked]")?;
105        }
106        if !self.text.is_empty() {
107            write!(f, " \"{}\"", self.text)?;
108        }
109        if let Some(ref v) = self.value {
110            write!(f, " value=\"{}\"", v)?;
111        }
112        if let Some(ref p) = self.placeholder {
113            write!(f, " placeholder=\"{}\"", p)?;
114        }
115        if let Some(ref r) = self.role {
116            let redundant = (r == "button" && self.tag == "button")
117                || (r == "link" && self.tag == "a")
118                || (r == "menuitem" && self.tag == "a");
119            if !redundant {
120                write!(f, " role=\"{}\"", r)?;
121            }
122        }
123        Ok(())
124    }
125}
126
127/// Configuration for observation behavior.
128#[derive(Debug, Clone)]
129pub struct ObserveConfig {
130    /// Only include elements visible in the current viewport.
131    /// Dramatically reduces token count on long pages. Default: true.
132    pub viewport_only: bool,
133}
134
135impl Default for ObserveConfig {
136    fn default() -> Self {
137        Self {
138            viewport_only: true,
139        }
140    }
141}
142
143/// Result of a diff-based observation.
144#[derive(Debug)]
145pub struct ObserveDiff {
146    /// Indices of elements that appeared since last observe.
147    pub added: Vec<usize>,
148    /// Count of elements that disappeared since last observe.
149    pub removed: usize,
150    /// Total element count after this observe.
151    pub total: usize,
152}
153
154impl fmt::Display for ObserveDiff {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        if self.added.is_empty() && self.removed == 0 {
157            write!(f, "no changes ({} elements)", self.total)
158        } else {
159            let mut need_sep = false;
160            if !self.added.is_empty() {
161                write!(f, "+{} added", self.added.len())?;
162                need_sep = true;
163            }
164            if self.removed > 0 {
165                if need_sep {
166                    write!(f, ", ")?;
167                }
168                write!(f, "-{} removed", self.removed)?;
169            }
170            write!(f, " ({} total)", self.total)
171        }
172    }
173}
174
175// =============================================================================
176// Session - owns Browser and Page
177// =============================================================================
178
179/// A browser session that owns its browser and page.
180/// This is the primary API for library usage. The MCP server uses raw `Page` directly.
181pub struct Session {
182    browser: Browser,
183    page: Page,
184    elements: Vec<InteractiveElement>,
185    config: ObserveConfig,
186}
187
188impl Session {
189    /// Launch a new browser and create an owned agent page.
190    pub async fn launch() -> Result<Self> {
191        let browser = Browser::launch().await?;
192        let page = browser.new_page("about:blank").await?;
193        Ok(Self {
194            browser,
195            page,
196            elements: Vec::new(),
197            config: ObserveConfig::default(),
198        })
199    }
200
201    /// Launch with custom stealth config.
202    pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
203        let browser = Browser::launch_with_config(stealth).await?;
204        let page = browser.new_page("about:blank").await?;
205        Ok(Self {
206            browser,
207            page,
208            elements: Vec::new(),
209            config: ObserveConfig::default(),
210        })
211    }
212
213    /// Set observation config.
214    pub fn set_observe_config(&mut self, config: ObserveConfig) {
215        self.config = config;
216    }
217
218    /// Get reference to underlying page.
219    pub fn page(&self) -> &Page {
220        &self.page
221    }
222
223    /// Get reference to browser.
224    pub fn browser(&self) -> &Browser {
225        &self.browser
226    }
227
228    // =========================================================================
229    // Observation
230    // =========================================================================
231
232    /// Snapshot the page: enumerate all interactive elements.
233    pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
234        self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
235        Ok(&self.elements)
236    }
237
238    /// Take an annotated screenshot with numbered boxes on each element.
239    pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
240        if self.elements.is_empty() {
241            self.observe().await?;
242        }
243        annotate::annotated_screenshot(&self.page, &self.elements).await
244    }
245
246    /// Compact text list for LLM consumption.
247    pub fn element_list(&self) -> String {
248        let mut out = String::with_capacity(self.elements.len() * 40);
249        for el in &self.elements {
250            out.push_str(&el.to_string());
251            out.push('\n');
252        }
253        out
254    }
255
256    /// Get element info by index.
257    pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
258        self.elements.get(index)
259    }
260
261    /// Get all observed elements.
262    pub fn elements(&self) -> &[InteractiveElement] {
263        &self.elements
264    }
265
266    /// Number of observed elements.
267    pub fn len(&self) -> usize {
268        self.elements.len()
269    }
270
271    /// Whether the element list is empty.
272    pub fn is_empty(&self) -> bool {
273        self.elements.is_empty()
274    }
275
276    /// Find first element whose text contains the given substring (case-insensitive).
277    pub fn find_by_text(&self, needle: &str) -> Option<usize> {
278        let needle_lower = needle.to_lowercase();
279        self.elements
280            .iter()
281            .find(|e| e.text.to_lowercase().contains(&needle_lower))
282            .map(|e| e.index)
283    }
284
285    /// Find all elements whose text contains the given substring (case-insensitive).
286    pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
287        let needle_lower = needle.to_lowercase();
288        self.elements
289            .iter()
290            .filter(|e| e.text.to_lowercase().contains(&needle_lower))
291            .map(|e| e.index)
292            .collect()
293    }
294
295    /// Observe and return a diff against the previous observation.
296    /// Use this in multi-step sessions to minimize tokens — only send
297    /// `added_element_list()` to the LLM instead of the full list.
298    pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
299        let old_selectors: HashSet<String> =
300            self.elements.iter().map(|e| e.selector.clone()).collect();
301
302        self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
303
304        let new_selectors: HashSet<&str> =
305            self.elements.iter().map(|e| e.selector.as_str()).collect();
306
307        let added: Vec<usize> = self
308            .elements
309            .iter()
310            .filter(|e| !old_selectors.contains(&e.selector))
311            .map(|e| e.index)
312            .collect();
313
314        let removed = old_selectors
315            .iter()
316            .filter(|s| !new_selectors.contains(s.as_str()))
317            .count();
318
319        Ok(ObserveDiff {
320            added,
321            removed,
322            total: self.elements.len(),
323        })
324    }
325
326    /// Compact text list of only the added elements from the last `observe_diff()`.
327    pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
328        let mut out = String::new();
329        for &idx in &diff.added {
330            if let Some(el) = self.elements.get(idx) {
331                out.push_str(&el.to_string());
332                out.push('\n');
333            }
334        }
335        out
336    }
337
338    /// Take a plain screenshot without annotations.
339    pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
340        self.page.screenshot().await
341    }
342
343    // =========================================================================
344    // Actions with auto-recovery
345    // =========================================================================
346
347    /// Get an element, verifying it still exists in DOM.
348    /// If element moved, returns error with hint about new location.
349    async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
350        // First check if element exists at index
351        let stored = self.elements.get(index).cloned();
352
353        if let Some(ref el) = stored {
354            // Verify the element still exists in DOM
355            let js = format!(
356                "!!document.querySelector({})",
357                serde_json::to_string(&el.selector).unwrap()
358            );
359            let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
360
361            if exists {
362                return self.elements.get(index).ok_or_else(|| {
363                    eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
364                });
365            }
366
367            // Element gone from DOM - re-observe and look for it
368            self.observe().await?;
369
370            // Try to find element with matching fingerprint
371            if let Some(new_idx) = self
372                .elements
373                .iter()
374                .position(|e| e.fingerprint == el.fingerprint)
375            {
376                // Found at different index - error with helpful message
377                return Err(eoka::Error::ElementNotFound(format!(
378                    "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
379                    index, el.text, new_idx
380                )));
381            }
382
383            return Err(eoka::Error::ElementNotFound(format!(
384                "element [{}] \"{}\" no longer exists on page",
385                index, el.text
386            )));
387        }
388
389        Err(eoka::Error::ElementNotFound(format!(
390            "element [{}] not found (observed {} elements)",
391            index,
392            self.elements.len()
393        )))
394    }
395
396    /// Click an element, auto-recovering if stale.
397    /// Clears element cache since clicks often trigger navigation/DOM changes.
398    pub async fn click(&mut self, index: usize) -> Result<()> {
399        let el = self.require_fresh(index).await?;
400        let selector = el.selector.clone();
401        self.page.click(&selector).await?;
402        self.wait_for_stable().await?;
403        self.elements.clear(); // Clicks often change the page
404        Ok(())
405    }
406
407    /// Fill an element, auto-recovering if stale.
408    /// Does NOT clear element cache (typing rarely changes DOM structure).
409    pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
410        let el = self.require_fresh(index).await?;
411        let selector = el.selector.clone();
412        self.page.fill(&selector, text).await?;
413        self.wait_for_stable().await?;
414        Ok(())
415    }
416
417    /// Select a dropdown option, auto-recovering if stale.
418    /// Clears element cache since onChange handlers may modify DOM.
419    pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
420        let el = self.require_fresh(index).await?;
421        let selector = el.selector.clone();
422        let arg = serde_json::json!({ "sel": selector, "val": value });
423        let js = format!(
424            r#"(() => {{
425                const arg = {arg};
426                const sel = document.querySelector(arg.sel);
427                if (!sel) return false;
428                const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
429                if (!opt) return false;
430                sel.value = opt.value;
431                sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
432                return true;
433            }})()"#,
434            arg = serde_json::to_string(&arg).unwrap()
435        );
436        let selected: bool = self.page.evaluate(&js).await?;
437        if !selected {
438            return Err(eoka::Error::ElementNotFound(format!(
439                "option \"{}\" in element [{}]",
440                value, index
441            )));
442        }
443        self.wait_for_stable().await?;
444        self.elements.clear(); // onChange handlers may modify DOM
445        Ok(())
446    }
447
448    /// Hover over element.
449    pub async fn hover(&mut self, index: usize) -> Result<()> {
450        let el = self.require_fresh(index).await?;
451        let cx = el.bbox.x + el.bbox.width / 2.0;
452        let cy = el.bbox.y + el.bbox.height / 2.0;
453        self.page
454            .session()
455            .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
456            .await
457    }
458
459    /// Scroll element into view.
460    pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
461        let el = self.require_fresh(index).await?;
462        let selector = el.selector.clone();
463        let js = format!(
464            "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
465            serde_json::to_string(&selector).unwrap()
466        );
467        self.page.execute(&js).await
468    }
469
470    /// Try to click — returns `Ok(false)` if element is missing or not visible.
471    pub async fn try_click(&mut self, index: usize) -> Result<bool> {
472        let el = self.require_fresh(index).await?;
473        let selector = el.selector.clone();
474        self.page.try_click(&selector).await
475    }
476
477    /// Human-like click by index.
478    pub async fn human_click(&mut self, index: usize) -> Result<()> {
479        let el = self.require_fresh(index).await?;
480        let selector = el.selector.clone();
481        self.page.human_click(&selector).await
482    }
483
484    /// Human-like fill by index.
485    pub async fn human_fill(&mut self, index: usize, text: &str) -> Result<()> {
486        let el = self.require_fresh(index).await?;
487        let selector = el.selector.clone();
488        self.page.human_fill(&selector, text).await
489    }
490
491    /// Focus an element by index.
492    pub async fn focus(&mut self, index: usize) -> Result<()> {
493        let el = self.require_fresh(index).await?;
494        let selector = el.selector.clone();
495        self.page
496            .execute(&format!(
497                "document.querySelector({})?.focus()",
498                serde_json::to_string(&selector).unwrap()
499            ))
500            .await
501    }
502
503    /// Focus element by index and press Enter (common for form submission).
504    pub async fn submit(&mut self, index: usize) -> Result<()> {
505        self.focus(index).await?;
506        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
507        self.page.human().press_key("Enter").await
508    }
509
510    /// Get dropdown options for a select element. Returns vec of (value, text) pairs.
511    pub async fn options(&mut self, index: usize) -> Result<Vec<(String, String)>> {
512        let el = self.require_fresh(index).await?;
513        let selector = el.selector.clone();
514        let js = format!(
515            r#"(() => {{
516                const sel = document.querySelector({});
517                if (!sel || !sel.options) return '[]';
518                return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
519            }})()"#,
520            serde_json::to_string(&selector).unwrap()
521        );
522        let json_str: String = self.page.evaluate(&js).await?;
523        let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
524            .map_err(|e| eoka::Error::CdpSimple(format!("options parse error: {}", e)))?;
525        Ok(pairs)
526    }
527
528    // =========================================================================
529    // Navigation
530    // =========================================================================
531
532    /// Navigate to a URL.
533    pub async fn goto(&mut self, url: &str) -> Result<()> {
534        self.elements.clear();
535        self.page.goto(url).await?;
536        self.wait_for_stable().await
537    }
538
539    /// Go back in history.
540    pub async fn back(&mut self) -> Result<()> {
541        self.elements.clear();
542        self.page.back().await?;
543        self.wait_for_stable().await
544    }
545
546    /// Go forward in history.
547    pub async fn forward(&mut self) -> Result<()> {
548        self.elements.clear();
549        self.page.forward().await?;
550        self.wait_for_stable().await
551    }
552
553    /// Reload the page.
554    pub async fn reload(&mut self) -> Result<()> {
555        self.elements.clear();
556        self.page.reload().await?;
557        self.wait_for_stable().await
558    }
559
560    // =========================================================================
561    // Page state
562    // =========================================================================
563
564    /// Get the current URL.
565    pub async fn url(&self) -> Result<String> {
566        self.page.url().await
567    }
568
569    /// Get the page title.
570    pub async fn title(&self) -> Result<String> {
571        self.page.title().await
572    }
573
574    /// Get visible text content of the page.
575    pub async fn text(&self) -> Result<String> {
576        self.page.text().await
577    }
578
579    // =========================================================================
580    // Scrolling
581    // =========================================================================
582
583    /// Scroll down by approximately one viewport height.
584    pub async fn scroll_down(&self) -> Result<()> {
585        self.page
586            .execute("window.scrollBy(0, window.innerHeight * 0.8)")
587            .await
588    }
589
590    /// Scroll up by approximately one viewport height.
591    pub async fn scroll_up(&self) -> Result<()> {
592        self.page
593            .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
594            .await
595    }
596
597    /// Scroll to top.
598    pub async fn scroll_to_top(&self) -> Result<()> {
599        self.page.execute("window.scrollTo(0, 0)").await
600    }
601
602    /// Scroll to bottom.
603    pub async fn scroll_to_bottom(&self) -> Result<()> {
604        self.page
605            .execute("window.scrollTo(0, document.body.scrollHeight)")
606            .await
607    }
608
609    // =========================================================================
610    // Smart Waiting
611    // =========================================================================
612
613    /// Wait for the page to stabilize after an action.
614    /// Waits up to 2s for network idle, then 50ms for DOM settle.
615    /// Intentionally succeeds even if network doesn't fully idle (some sites never stop polling).
616    pub async fn wait_for_stable(&self) -> Result<()> {
617        // Best-effort network wait - ignore timeout (some sites have constant polling)
618        let _ = self.page.wait_for_network_idle(200, 2000).await;
619        // Brief DOM settle time
620        self.page.wait(50).await;
621        Ok(())
622    }
623
624    /// Fixed delay in milliseconds.
625    pub async fn wait(&self, ms: u64) {
626        self.page.wait(ms).await;
627    }
628
629    /// Wait for text to appear on the page.
630    pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
631        self.page.wait_for_text(text, timeout_ms).await?;
632        Ok(())
633    }
634
635    /// Wait for a URL pattern (substring match).
636    pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
637        self.page.wait_for_url_contains(pattern, timeout_ms).await
638    }
639
640    /// Wait for network activity to settle.
641    pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
642        self.page.wait_for_network_idle(500, timeout_ms).await
643    }
644
645    // =========================================================================
646    // Keyboard
647    // =========================================================================
648
649    /// Press a key.
650    pub async fn press_key(&self, key: &str) -> Result<()> {
651        self.page.human().press_key(key).await
652    }
653
654    // =========================================================================
655    // JavaScript
656    // =========================================================================
657
658    /// Evaluate JavaScript and return the result.
659    pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
660        self.page.evaluate(js).await
661    }
662
663    /// Execute JavaScript (no return value).
664    pub async fn exec(&self, js: &str) -> Result<()> {
665        self.page.execute(js).await
666    }
667
668    /// Extract structured data from the page using a JS expression that returns JSON.
669    ///
670    /// Example:
671    /// ```rust,no_run
672    /// # use eoka_agent::Session;
673    /// # async fn example(session: &Session) -> eoka::Result<()> {
674    /// let titles: Vec<String> = session.extract(
675    ///     "Array.from(document.querySelectorAll('h2')).map(h => h.textContent.trim())"
676    /// ).await?;
677    /// # Ok(())
678    /// # }
679    /// ```
680    pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
681        let escaped_js = serde_json::to_string(js_expression)
682            .map_err(|e| eoka::Error::CdpSimple(format!("Failed to escape JS: {}", e)))?;
683        let js = format!("JSON.stringify(eval({}))", escaped_js);
684        let json_str: String = self.page.evaluate(&js).await?;
685        if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
686            return Err(eoka::Error::CdpSimple(format!(
687                "extract returned null/undefined for: {}",
688                if js_expression.len() > 60 {
689                    &js_expression[..60]
690                } else {
691                    js_expression
692                }
693            )));
694        }
695        serde_json::from_str(&json_str).map_err(|e| {
696            eoka::Error::CdpSimple(format!(
697                "extract parse error: {} (got: {})",
698                e,
699                if json_str.len() > 80 {
700                    &json_str[..80]
701                } else {
702                    &json_str
703                }
704            ))
705        })
706    }
707
708    // =========================================================================
709    // SPA Navigation
710    // =========================================================================
711
712    /// Detect the SPA router type and current route state.
713    pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
714        spa::detect_router(&self.page).await
715    }
716
717    /// Navigate the SPA to a new path without page reload.
718    /// Automatically detects the router type and uses the appropriate navigation method.
719    /// Clears element cache since the DOM will change.
720    pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
721        let info = spa::detect_router(&self.page).await?;
722        let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
723        self.elements.clear();
724        Ok(result)
725    }
726
727    /// Navigate browser history by delta steps.
728    /// delta = -1 goes back, delta = 1 goes forward.
729    /// Clears element cache since the DOM will change.
730    pub async fn history_go(&mut self, delta: i32) -> Result<()> {
731        spa::history_go(&self.page, delta).await?;
732        self.elements.clear();
733        Ok(())
734    }
735
736    // =========================================================================
737    // Cleanup
738    // =========================================================================
739
740    /// Close the browser.
741    pub async fn close(self) -> Result<()> {
742        self.browser.close().await
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    fn make_element(
751        index: usize,
752        tag: &str,
753        text: &str,
754        role: Option<&str>,
755        input_type: Option<&str>,
756        placeholder: Option<&str>,
757        value: Option<&str>,
758        checked: bool,
759    ) -> InteractiveElement {
760        let selector = format!("[data-idx=\"{}\"]", index);
761        let fingerprint = InteractiveElement::compute_fingerprint(
762            tag,
763            text,
764            role,
765            input_type,
766            placeholder,
767            &selector,
768        );
769        InteractiveElement {
770            index,
771            tag: tag.to_string(),
772            text: text.to_string(),
773            role: role.map(|s| s.to_string()),
774            input_type: input_type.map(|s| s.to_string()),
775            placeholder: placeholder.map(|s| s.to_string()),
776            value: value.map(|s| s.to_string()),
777            checked,
778            selector,
779            bbox: BoundingBox {
780                x: 0.0,
781                y: 0.0,
782                width: 100.0,
783                height: 30.0,
784            },
785            fingerprint,
786        }
787    }
788
789    #[test]
790    fn test_element_display_basic() {
791        let el = make_element(0, "button", "Submit", None, None, None, None, false);
792        assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
793    }
794
795    #[test]
796    fn test_element_display_with_input_type() {
797        // text type is suppressed
798        let el = make_element(0, "input", "", None, Some("text"), None, None, false);
799        assert_eq!(el.to_string(), "[0] <input>");
800
801        // other types are shown
802        let el = make_element(0, "input", "", None, Some("password"), None, None, false);
803        assert_eq!(el.to_string(), "[0] <input type=\"password\">");
804    }
805
806    #[test]
807    fn test_element_display_with_placeholder() {
808        let el = make_element(
809            0,
810            "input",
811            "",
812            None,
813            Some("text"),
814            Some("Enter email"),
815            None,
816            false,
817        );
818        assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
819    }
820
821    #[test]
822    fn test_element_display_with_value() {
823        let el = make_element(
824            0,
825            "input",
826            "",
827            None,
828            Some("text"),
829            None,
830            Some("hello"),
831            false,
832        );
833        assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
834    }
835
836    #[test]
837    fn test_element_display_checked() {
838        let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
839        assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
840    }
841
842    #[test]
843    fn test_element_display_redundant_role_suppressed() {
844        // button role on button tag is redundant
845        let el = make_element(
846            0,
847            "button",
848            "Click",
849            Some("button"),
850            None,
851            None,
852            None,
853            false,
854        );
855        assert_eq!(el.to_string(), "[0] <button> \"Click\"");
856
857        // link role on a tag is redundant
858        let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
859        assert_eq!(el.to_string(), "[0] <a> \"Link\"");
860
861        // menuitem role on a tag is redundant
862        let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
863        assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
864    }
865
866    #[test]
867    fn test_element_display_non_redundant_role_shown() {
868        // tab role on button is meaningful
869        let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
870        assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
871
872        // button role on div is meaningful
873        let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
874        assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
875    }
876
877    #[test]
878    fn test_observe_diff_display_no_changes() {
879        let diff = ObserveDiff {
880            added: vec![],
881            removed: 0,
882            total: 5,
883        };
884        assert_eq!(diff.to_string(), "no changes (5 elements)");
885    }
886
887    #[test]
888    fn test_observe_diff_display_added_only() {
889        let diff = ObserveDiff {
890            added: vec![5, 6],
891            removed: 0,
892            total: 7,
893        };
894        assert_eq!(diff.to_string(), "+2 added (7 total)");
895    }
896
897    #[test]
898    fn test_observe_diff_display_removed_only() {
899        let diff = ObserveDiff {
900            added: vec![],
901            removed: 3,
902            total: 2,
903        };
904        assert_eq!(diff.to_string(), "-3 removed (2 total)");
905    }
906
907    #[test]
908    fn test_observe_diff_display_both() {
909        let diff = ObserveDiff {
910            added: vec![3, 4],
911            removed: 1,
912            total: 5,
913        };
914        assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
915    }
916
917    #[test]
918    fn test_observe_config_default() {
919        let config = ObserveConfig::default();
920        assert!(config.viewport_only);
921    }
922
923    #[test]
924    fn test_fingerprint_uses_full_selector() {
925        // Two selectors identical up to char 50 but different after
926        let base = "a".repeat(50);
927        let sel_a = format!("{}AAAA", base);
928        let sel_b = format!("{}BBBB", base);
929
930        let fp_a = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_a);
931        let fp_b = InteractiveElement::compute_fingerprint("button", "X", None, None, None, &sel_b);
932
933        assert_ne!(
934            fp_a, fp_b,
935            "selectors differing after char 50 should produce different fingerprints"
936        );
937    }
938}