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