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 selector prefix (first 50 chars) for positional uniqueness
89        selector[..selector.len().min(50)].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/// Wraps a `Page` with agent-friendly observation and interaction methods.
176///
177/// The core loop is: `observe()` → read `element_list()` → `click(i)` / `fill(i, text)`.
178pub struct AgentPage<'a> {
179    page: &'a Page,
180    elements: Vec<InteractiveElement>,
181    config: ObserveConfig,
182}
183
184impl<'a> AgentPage<'a> {
185    /// Create an AgentPage wrapping an existing eoka Page.
186    pub fn new(page: &'a Page) -> Self {
187        Self {
188            page,
189            elements: Vec::new(),
190            config: ObserveConfig::default(),
191        }
192    }
193
194    /// Create with custom observation config.
195    pub fn with_config(page: &'a Page, config: ObserveConfig) -> Self {
196        Self {
197            page,
198            elements: Vec::new(),
199            config,
200        }
201    }
202
203    /// Get a reference to the underlying Page.
204    pub fn page(&self) -> &Page {
205        self.page
206    }
207
208    // =========================================================================
209    // Observation
210    // =========================================================================
211
212    /// Snapshot the page: enumerate all interactive elements.
213    pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
214        self.elements = observe::observe(self.page, self.config.viewport_only).await?;
215        Ok(&self.elements)
216    }
217
218    /// Observe and return a diff against the previous observation.
219    /// Use this in multi-step sessions to minimize tokens — only send
220    /// `added_element_list()` to the LLM instead of the full list.
221    pub async fn observe_diff(&mut self) -> Result<ObserveDiff> {
222        let old_selectors: HashSet<String> =
223            self.elements.iter().map(|e| e.selector.clone()).collect();
224
225        self.elements = observe::observe(self.page, self.config.viewport_only).await?;
226
227        let new_selectors: HashSet<&str> =
228            self.elements.iter().map(|e| e.selector.as_str()).collect();
229
230        let added: Vec<usize> = self
231            .elements
232            .iter()
233            .filter(|e| !old_selectors.contains(&e.selector))
234            .map(|e| e.index)
235            .collect();
236
237        let removed = old_selectors
238            .iter()
239            .filter(|s| !new_selectors.contains(s.as_str()))
240            .count();
241
242        Ok(ObserveDiff {
243            added,
244            removed,
245            total: self.elements.len(),
246        })
247    }
248
249    /// Compact text list of only the added elements from the last `observe_diff()`.
250    pub fn added_element_list(&self, diff: &ObserveDiff) -> String {
251        let mut out = String::new();
252        for &idx in &diff.added {
253            if let Some(el) = self.elements.get(idx) {
254                out.push_str(&el.to_string());
255                out.push('\n');
256            }
257        }
258        out
259    }
260
261    /// Take an annotated screenshot with numbered boxes on each element.
262    /// Calls `observe()` first if no elements have been enumerated yet.
263    pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
264        if self.elements.is_empty() {
265            self.observe().await?;
266        }
267        annotate::annotated_screenshot(self.page, &self.elements).await
268    }
269
270    /// Take a plain screenshot without annotations.
271    pub async fn screenshot_plain(&self) -> Result<Vec<u8>> {
272        self.page.screenshot().await
273    }
274
275    /// Compact text list for LLM consumption.
276    /// Each line: `[index] <tag type="x"> "text" placeholder="y"`
277    pub fn element_list(&self) -> String {
278        let mut out = String::with_capacity(self.elements.len() * 40);
279        for el in &self.elements {
280            out.push_str(&el.to_string());
281            out.push('\n');
282        }
283        out
284    }
285
286    /// Get element info by index.
287    pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
288        self.elements.get(index)
289    }
290
291    /// Get all observed elements.
292    pub fn elements(&self) -> &[InteractiveElement] {
293        &self.elements
294    }
295
296    /// Number of observed elements.
297    pub fn len(&self) -> usize {
298        self.elements.len()
299    }
300
301    /// Whether the element list is empty.
302    pub fn is_empty(&self) -> bool {
303        self.elements.is_empty()
304    }
305
306    /// Find first element whose text contains the given substring (case-insensitive).
307    /// Returns the element index, or None.
308    pub fn find_by_text(&self, needle: &str) -> Option<usize> {
309        let needle_lower = needle.to_lowercase();
310        self.elements
311            .iter()
312            .find(|e| e.text.to_lowercase().contains(&needle_lower))
313            .map(|e| e.index)
314    }
315
316    /// Find all elements whose text contains the given substring (case-insensitive).
317    pub fn find_all_by_text(&self, needle: &str) -> Vec<usize> {
318        let needle_lower = needle.to_lowercase();
319        self.elements
320            .iter()
321            .filter(|e| e.text.to_lowercase().contains(&needle_lower))
322            .map(|e| e.index)
323            .collect()
324    }
325
326    // =========================================================================
327    // Actions (index-based)
328    // =========================================================================
329
330    /// Click an element by its index.
331    pub async fn click(&self, index: usize) -> Result<()> {
332        let el = self.require(index)?;
333        self.page.click(&el.selector).await
334    }
335
336    /// Try to click — returns `Ok(false)` if element is missing or not visible.
337    pub async fn try_click(&self, index: usize) -> Result<bool> {
338        let el = self.require(index)?;
339        self.page.try_click(&el.selector).await
340    }
341
342    /// Human-like click by index.
343    pub async fn human_click(&self, index: usize) -> Result<()> {
344        let el = self.require(index)?;
345        self.page.human_click(&el.selector).await
346    }
347
348    /// Clear and type into an element by index.
349    pub async fn fill(&self, index: usize, text: &str) -> Result<()> {
350        let el = self.require(index)?;
351        self.page.fill(&el.selector, text).await
352    }
353
354    /// Human-like fill by index.
355    pub async fn human_fill(&self, index: usize, text: &str) -> Result<()> {
356        let el = self.require(index)?;
357        self.page.human_fill(&el.selector, text).await
358    }
359
360    /// Focus an element by index.
361    pub async fn focus(&self, index: usize) -> Result<()> {
362        let el = self.require(index)?;
363        self.page
364            .execute(&format!(
365                "document.querySelector({})?.focus()",
366                serde_json::to_string(&el.selector).unwrap()
367            ))
368            .await
369    }
370
371    /// Select a dropdown option by index. `value` matches the option's value or visible text.
372    pub async fn select(&self, index: usize, value: &str) -> Result<()> {
373        let el = self.require(index)?;
374        let arg = serde_json::json!({ "sel": el.selector, "val": value });
375        let js = format!(
376            r#"(() => {{
377                const arg = {arg};
378                const sel = document.querySelector(arg.sel);
379                if (!sel) return false;
380                const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
381                if (!opt) return false;
382                sel.value = opt.value;
383                sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
384                return true;
385            }})()"#,
386            arg = serde_json::to_string(&arg).unwrap()
387        );
388        let selected: bool = self.page.evaluate(&js).await?;
389        if !selected {
390            return Err(eoka::Error::ElementNotFound(format!(
391                "option \"{}\" in element [{}]",
392                value, index
393            )));
394        }
395        Ok(())
396    }
397
398    /// Get dropdown options for a select element. Returns vec of (value, text) pairs.
399    pub async fn options(&self, index: usize) -> Result<Vec<(String, String)>> {
400        let el = self.require(index)?;
401        let js = format!(
402            r#"(() => {{
403                const sel = document.querySelector({});
404                if (!sel || !sel.options) return '[]';
405                return JSON.stringify(Array.from(sel.options).map(o => [o.value, o.text]));
406            }})()"#,
407            serde_json::to_string(&el.selector).unwrap()
408        );
409        let json_str: String = self.page.evaluate(&js).await?;
410        let pairs: Vec<(String, String)> = serde_json::from_str(&json_str)
411            .map_err(|e| eoka::Error::CdpSimple(format!("options parse error: {}", e)))?;
412        Ok(pairs)
413    }
414
415    /// Scroll element at index into view.
416    pub async fn scroll_to(&self, index: usize) -> Result<()> {
417        let el = self.require(index)?;
418        let js = format!(
419            "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
420            serde_json::to_string(&el.selector).unwrap()
421        );
422        self.page.execute(&js).await
423    }
424
425    // =========================================================================
426    // Navigation
427    // =========================================================================
428
429    /// Navigate to a URL. Clears element list (call `observe()` after navigation).
430    pub async fn goto(&mut self, url: &str) -> Result<()> {
431        self.elements.clear();
432        self.page.goto(url).await
433    }
434
435    /// Go back in history. Clears element list (call `observe()` after navigation).
436    pub async fn back(&mut self) -> Result<()> {
437        self.elements.clear();
438        self.page.back().await
439    }
440
441    /// Go forward in history. Clears element list (call `observe()` after navigation).
442    pub async fn forward(&mut self) -> Result<()> {
443        self.elements.clear();
444        self.page.forward().await
445    }
446
447    /// Reload the page. Clears element list (call `observe()` after navigation).
448    pub async fn reload(&mut self) -> Result<()> {
449        self.elements.clear();
450        self.page.reload().await
451    }
452
453    // =========================================================================
454    // Page state
455    // =========================================================================
456
457    /// Get the current URL.
458    pub async fn url(&self) -> Result<String> {
459        self.page.url().await
460    }
461
462    /// Get the page title.
463    pub async fn title(&self) -> Result<String> {
464        self.page.title().await
465    }
466
467    /// Get visible text content of the page.
468    pub async fn text(&self) -> Result<String> {
469        self.page.text().await
470    }
471
472    // =========================================================================
473    // Scrolling
474    // =========================================================================
475
476    /// Scroll down by approximately one viewport height.
477    pub async fn scroll_down(&self) -> Result<()> {
478        self.page
479            .execute("window.scrollBy(0, window.innerHeight * 0.8)")
480            .await
481    }
482
483    /// Scroll up by approximately one viewport height.
484    pub async fn scroll_up(&self) -> Result<()> {
485        self.page
486            .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
487            .await
488    }
489
490    /// Scroll to the top of the page.
491    pub async fn scroll_to_top(&self) -> Result<()> {
492        self.page.execute("window.scrollTo(0, 0)").await
493    }
494
495    /// Scroll to the bottom of the page.
496    pub async fn scroll_to_bottom(&self) -> Result<()> {
497        self.page
498            .execute("window.scrollTo(0, document.body.scrollHeight)")
499            .await
500    }
501
502    // =========================================================================
503    // Waiting
504    // =========================================================================
505
506    /// Wait for text to appear on the page.
507    pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<()> {
508        self.page.wait_for_text(text, timeout_ms).await?;
509        Ok(())
510    }
511
512    /// Wait for a URL pattern (substring match).
513    pub async fn wait_for_url(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
514        self.page.wait_for_url_contains(pattern, timeout_ms).await
515    }
516
517    /// Wait for network activity to settle.
518    pub async fn wait_for_idle(&self, timeout_ms: u64) -> Result<()> {
519        self.page.wait_for_network_idle(500, timeout_ms).await
520    }
521
522    /// Fixed delay in milliseconds.
523    pub async fn wait(&self, ms: u64) {
524        self.page.wait(ms).await;
525    }
526
527    // =========================================================================
528    // JavaScript
529    // =========================================================================
530
531    /// Evaluate JavaScript and return the result.
532    pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
533        self.page.evaluate(js).await
534    }
535
536    /// Execute JavaScript (no return value).
537    pub async fn exec(&self, js: &str) -> Result<()> {
538        self.page.execute(js).await
539    }
540
541    // =========================================================================
542    // Keyboard
543    // =========================================================================
544
545    /// Press a key (e.g. "Enter", "Tab", "Escape", "ArrowDown", "Backspace").
546    pub async fn press_key(&self, key: &str) -> Result<()> {
547        self.page.human().press_key(key).await
548    }
549
550    /// Focus element by index and press Enter (common for form submission).
551    pub async fn submit(&self, index: usize) -> Result<()> {
552        self.focus(index).await?;
553        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
554        self.page.human().press_key("Enter").await
555    }
556
557    // =========================================================================
558    // Hover
559    // =========================================================================
560
561    /// Hover over element by index (triggers hover states, tooltips, menus).
562    pub async fn hover(&self, index: usize) -> Result<()> {
563        let el = self.require(index)?;
564        let cx = el.bbox.x + el.bbox.width / 2.0;
565        let cy = el.bbox.y + el.bbox.height / 2.0;
566        self.page
567            .session()
568            .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
569            .await
570    }
571
572    // =========================================================================
573    // Extraction
574    // =========================================================================
575
576    /// Extract structured data from the page using a JS expression that returns JSON.
577    ///
578    /// Example:
579    /// ```rust,no_run
580    /// # use eoka_agent::AgentPage;
581    /// # async fn example(agent: &AgentPage<'_>) -> eoka::Result<()> {
582    /// let titles: Vec<String> = agent.extract(
583    ///     "Array.from(document.querySelectorAll('h2')).map(h => h.textContent.trim())"
584    /// ).await?;
585    /// # Ok(())
586    /// # }
587    /// ```
588    pub async fn extract<T: serde::de::DeserializeOwned>(&self, js_expression: &str) -> Result<T> {
589        // Use eval() to handle multi-statement code - returns value of last expression
590        // Safely escape the JS code to prevent injection
591        let escaped_js = serde_json::to_string(js_expression)
592            .map_err(|e| eoka::Error::CdpSimple(format!("Failed to escape JS: {}", e)))?;
593        let js = format!("JSON.stringify(eval({}))", escaped_js);
594        let json_str: String = self.page.evaluate(&js).await?;
595        if json_str == "null" || json_str == "undefined" || json_str.is_empty() {
596            return Err(eoka::Error::CdpSimple(format!(
597                "extract returned null/undefined for: {}",
598                if js_expression.len() > 60 {
599                    &js_expression[..60]
600                } else {
601                    js_expression
602                }
603            )));
604        }
605        serde_json::from_str(&json_str).map_err(|e| {
606            eoka::Error::CdpSimple(format!(
607                "extract parse error: {} (got: {})",
608                e,
609                if json_str.len() > 80 {
610                    &json_str[..80]
611                } else {
612                    &json_str
613                }
614            ))
615        })
616    }
617
618    // =========================================================================
619    // Smart Waiting
620    // =========================================================================
621
622    /// Wait for the page to stabilize after an action.
623    /// Waits up to 2s for network idle, then 50ms for DOM settle.
624    /// Intentionally succeeds even if network doesn't fully idle (some sites never stop polling).
625    pub async fn wait_for_stable(&self) -> Result<()> {
626        // Best-effort network wait - ignore timeout (some sites have constant polling)
627        let _ = self.page.wait_for_network_idle(200, 2000).await;
628        // Brief DOM settle time
629        self.page.wait(50).await;
630        Ok(())
631    }
632
633    /// Click an element and wait for page to stabilize.
634    pub async fn click_and_wait(&mut self, index: usize) -> Result<()> {
635        self.click(index).await?;
636        self.wait_for_stable().await?;
637        // Invalidate elements since page likely changed
638        self.elements.clear();
639        Ok(())
640    }
641
642    /// Fill an element and wait for page to stabilize.
643    pub async fn fill_and_wait(&mut self, index: usize, text: &str) -> Result<()> {
644        self.fill(index, text).await?;
645        self.wait_for_stable().await?;
646        Ok(())
647    }
648
649    /// Select an option and wait for page to stabilize.
650    pub async fn select_and_wait(&mut self, index: usize, value: &str) -> Result<()> {
651        self.select(index, value).await?;
652        self.wait_for_stable().await?;
653        Ok(())
654    }
655
656    // =========================================================================
657    // SPA Navigation
658    // =========================================================================
659
660    /// Detect the SPA router type and current route state.
661    pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
662        spa::detect_router(self.page).await
663    }
664
665    /// Navigate the SPA to a new path without page reload.
666    /// Automatically detects the router type and uses the appropriate navigation method.
667    /// Clears element list since the DOM will change.
668    pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
669        let info = spa::detect_router(self.page).await?;
670        let result = spa::spa_navigate(self.page, &info.router_type, path).await?;
671        self.elements.clear();
672        Ok(result)
673    }
674
675    /// Navigate browser history by delta steps.
676    /// delta = -1 goes back, delta = 1 goes forward.
677    /// Clears element list since the DOM will change.
678    pub async fn history_go(&mut self, delta: i32) -> Result<()> {
679        spa::history_go(self.page, delta).await?;
680        self.elements.clear();
681        Ok(())
682    }
683
684    // =========================================================================
685    // Internal
686    // =========================================================================
687
688    fn require(&self, index: usize) -> Result<&InteractiveElement> {
689        self.elements.get(index).ok_or_else(|| {
690            eoka::Error::ElementNotFound(format!(
691                "element [{}] (observed {} elements — call observe() to refresh)",
692                index,
693                self.elements.len()
694            ))
695        })
696    }
697}
698
699// =============================================================================
700// Session - owns Browser and Page, no lifetime gymnastics
701// =============================================================================
702
703/// A browser session that owns its browser and page.
704/// This is the primary API for most use cases.
705pub struct Session {
706    browser: Browser,
707    page: Page,
708    elements: Vec<InteractiveElement>,
709    config: ObserveConfig,
710}
711
712impl Session {
713    /// Launch a new browser and create an owned agent page.
714    pub async fn launch() -> Result<Self> {
715        let browser = Browser::launch().await?;
716        let page = browser.new_page("about:blank").await?;
717        Ok(Self {
718            browser,
719            page,
720            elements: Vec::new(),
721            config: ObserveConfig::default(),
722        })
723    }
724
725    /// Launch with custom stealth config.
726    pub async fn launch_with_config(stealth: StealthConfig) -> Result<Self> {
727        let browser = Browser::launch_with_config(stealth).await?;
728        let page = browser.new_page("about:blank").await?;
729        Ok(Self {
730            browser,
731            page,
732            elements: Vec::new(),
733            config: ObserveConfig::default(),
734        })
735    }
736
737    /// Set observation config.
738    pub fn set_observe_config(&mut self, config: ObserveConfig) {
739        self.config = config;
740    }
741
742    /// Get reference to underlying page.
743    pub fn page(&self) -> &Page {
744        &self.page
745    }
746
747    /// Get reference to browser.
748    pub fn browser(&self) -> &Browser {
749        &self.browser
750    }
751
752    // =========================================================================
753    // Observation
754    // =========================================================================
755
756    /// Snapshot the page: enumerate all interactive elements.
757    pub async fn observe(&mut self) -> Result<&[InteractiveElement]> {
758        self.elements = observe::observe(&self.page, self.config.viewport_only).await?;
759        Ok(&self.elements)
760    }
761
762    /// Take an annotated screenshot with numbered boxes on each element.
763    pub async fn screenshot(&mut self) -> Result<Vec<u8>> {
764        if self.elements.is_empty() {
765            self.observe().await?;
766        }
767        annotate::annotated_screenshot(&self.page, &self.elements).await
768    }
769
770    /// Compact text list for LLM consumption.
771    pub fn element_list(&self) -> String {
772        let mut out = String::with_capacity(self.elements.len() * 40);
773        for el in &self.elements {
774            out.push_str(&el.to_string());
775            out.push('\n');
776        }
777        out
778    }
779
780    /// Get element info by index.
781    pub fn get(&self, index: usize) -> Option<&InteractiveElement> {
782        self.elements.get(index)
783    }
784
785    /// Get all observed elements.
786    pub fn elements(&self) -> &[InteractiveElement] {
787        &self.elements
788    }
789
790    /// Number of observed elements.
791    pub fn len(&self) -> usize {
792        self.elements.len()
793    }
794
795    /// Whether the element list is empty.
796    pub fn is_empty(&self) -> bool {
797        self.elements.is_empty()
798    }
799
800    /// Find first element whose text contains the given substring (case-insensitive).
801    pub fn find_by_text(&self, needle: &str) -> Option<usize> {
802        let needle_lower = needle.to_lowercase();
803        self.elements
804            .iter()
805            .find(|e| e.text.to_lowercase().contains(&needle_lower))
806            .map(|e| e.index)
807    }
808
809    // =========================================================================
810    // Actions with auto-recovery
811    // =========================================================================
812
813    /// Get an element, verifying it still exists in DOM.
814    /// If element moved, returns error with hint about new location.
815    async fn require_fresh(&mut self, index: usize) -> Result<&InteractiveElement> {
816        // First check if element exists at index
817        let stored = self.elements.get(index).cloned();
818
819        if let Some(ref el) = stored {
820            // Verify the element still exists in DOM
821            let js = format!(
822                "!!document.querySelector({})",
823                serde_json::to_string(&el.selector).unwrap()
824            );
825            let exists: bool = self.page.evaluate(&js).await.unwrap_or(false);
826
827            if exists {
828                return self.elements.get(index).ok_or_else(|| {
829                    eoka::Error::ElementNotFound(format!("element [{}] disappeared", index))
830                });
831            }
832
833            // Element gone from DOM - re-observe and look for it
834            self.observe().await?;
835
836            // Try to find element with matching fingerprint
837            if let Some(new_idx) = self
838                .elements
839                .iter()
840                .position(|e| e.fingerprint == el.fingerprint)
841            {
842                // Found at different index - error with helpful message
843                return Err(eoka::Error::ElementNotFound(format!(
844                    "element [{}] \"{}\" moved to [{}] - call observe() to refresh",
845                    index, el.text, new_idx
846                )));
847            }
848
849            return Err(eoka::Error::ElementNotFound(format!(
850                "element [{}] \"{}\" no longer exists on page",
851                index, el.text
852            )));
853        }
854
855        Err(eoka::Error::ElementNotFound(format!(
856            "element [{}] not found (observed {} elements)",
857            index,
858            self.elements.len()
859        )))
860    }
861
862    /// Click an element, auto-recovering if stale.
863    /// Clears element cache since clicks often trigger navigation/DOM changes.
864    pub async fn click(&mut self, index: usize) -> Result<()> {
865        let el = self.require_fresh(index).await?;
866        let selector = el.selector.clone();
867        self.page.click(&selector).await?;
868        self.wait_for_stable().await?;
869        self.elements.clear(); // Clicks often change the page
870        Ok(())
871    }
872
873    /// Fill an element, auto-recovering if stale.
874    /// Does NOT clear element cache (typing rarely changes DOM structure).
875    pub async fn fill(&mut self, index: usize, text: &str) -> Result<()> {
876        let el = self.require_fresh(index).await?;
877        let selector = el.selector.clone();
878        self.page.fill(&selector, text).await?;
879        self.wait_for_stable().await?;
880        Ok(())
881    }
882
883    /// Select a dropdown option, auto-recovering if stale.
884    /// Clears element cache since onChange handlers may modify DOM.
885    pub async fn select(&mut self, index: usize, value: &str) -> Result<()> {
886        let el = self.require_fresh(index).await?;
887        let selector = el.selector.clone();
888        let arg = serde_json::json!({ "sel": selector, "val": value });
889        let js = format!(
890            r#"(() => {{
891                const arg = {arg};
892                const sel = document.querySelector(arg.sel);
893                if (!sel) return false;
894                const opt = Array.from(sel.options).find(o => o.value === arg.val || o.text === arg.val);
895                if (!opt) return false;
896                sel.value = opt.value;
897                sel.dispatchEvent(new Event('change', {{ bubbles: true }}));
898                return true;
899            }})()"#,
900            arg = serde_json::to_string(&arg).unwrap()
901        );
902        let selected: bool = self.page.evaluate(&js).await?;
903        if !selected {
904            return Err(eoka::Error::ElementNotFound(format!(
905                "option \"{}\" in element [{}]",
906                value, index
907            )));
908        }
909        self.wait_for_stable().await?;
910        self.elements.clear(); // onChange handlers may modify DOM
911        Ok(())
912    }
913
914    /// Hover over element.
915    pub async fn hover(&mut self, index: usize) -> Result<()> {
916        let el = self.require_fresh(index).await?;
917        let cx = el.bbox.x + el.bbox.width / 2.0;
918        let cy = el.bbox.y + el.bbox.height / 2.0;
919        self.page
920            .session()
921            .dispatch_mouse_event(eoka::cdp::MouseEventType::MouseMoved, cx, cy, None, None)
922            .await
923    }
924
925    /// Scroll element into view.
926    pub async fn scroll_to(&mut self, index: usize) -> Result<()> {
927        let el = self.require_fresh(index).await?;
928        let selector = el.selector.clone();
929        let js = format!(
930            "document.querySelector({})?.scrollIntoView({{behavior:'smooth',block:'center'}})",
931            serde_json::to_string(&selector).unwrap()
932        );
933        self.page.execute(&js).await
934    }
935
936    // =========================================================================
937    // Navigation
938    // =========================================================================
939
940    /// Navigate to a URL.
941    pub async fn goto(&mut self, url: &str) -> Result<()> {
942        self.elements.clear();
943        self.page.goto(url).await?;
944        self.wait_for_stable().await
945    }
946
947    /// Go back in history.
948    pub async fn back(&mut self) -> Result<()> {
949        self.elements.clear();
950        self.page.back().await?;
951        self.wait_for_stable().await
952    }
953
954    /// Go forward in history.
955    pub async fn forward(&mut self) -> Result<()> {
956        self.elements.clear();
957        self.page.forward().await?;
958        self.wait_for_stable().await
959    }
960
961    // =========================================================================
962    // Page state
963    // =========================================================================
964
965    /// Get the current URL.
966    pub async fn url(&self) -> Result<String> {
967        self.page.url().await
968    }
969
970    /// Get the page title.
971    pub async fn title(&self) -> Result<String> {
972        self.page.title().await
973    }
974
975    /// Get visible text content of the page.
976    pub async fn text(&self) -> Result<String> {
977        self.page.text().await
978    }
979
980    // =========================================================================
981    // Scrolling
982    // =========================================================================
983
984    /// Scroll down by approximately one viewport height.
985    pub async fn scroll_down(&self) -> Result<()> {
986        self.page
987            .execute("window.scrollBy(0, window.innerHeight * 0.8)")
988            .await
989    }
990
991    /// Scroll up by approximately one viewport height.
992    pub async fn scroll_up(&self) -> Result<()> {
993        self.page
994            .execute("window.scrollBy(0, -window.innerHeight * 0.8)")
995            .await
996    }
997
998    /// Scroll to top.
999    pub async fn scroll_to_top(&self) -> Result<()> {
1000        self.page.execute("window.scrollTo(0, 0)").await
1001    }
1002
1003    /// Scroll to bottom.
1004    pub async fn scroll_to_bottom(&self) -> Result<()> {
1005        self.page
1006            .execute("window.scrollTo(0, document.body.scrollHeight)")
1007            .await
1008    }
1009
1010    // =========================================================================
1011    // Smart Waiting
1012    // =========================================================================
1013
1014    /// Wait for the page to stabilize after an action.
1015    /// Waits up to 2s for network idle, then 50ms for DOM settle.
1016    /// Intentionally succeeds even if network doesn't fully idle (some sites never stop polling).
1017    pub async fn wait_for_stable(&self) -> Result<()> {
1018        // Best-effort network wait - ignore timeout (some sites have constant polling)
1019        let _ = self.page.wait_for_network_idle(200, 2000).await;
1020        // Brief DOM settle time
1021        self.page.wait(50).await;
1022        Ok(())
1023    }
1024
1025    /// Fixed delay in milliseconds.
1026    pub async fn wait(&self, ms: u64) {
1027        self.page.wait(ms).await;
1028    }
1029
1030    // =========================================================================
1031    // Keyboard
1032    // =========================================================================
1033
1034    /// Press a key.
1035    pub async fn press_key(&self, key: &str) -> Result<()> {
1036        self.page.human().press_key(key).await
1037    }
1038
1039    // =========================================================================
1040    // JavaScript
1041    // =========================================================================
1042
1043    /// Evaluate JavaScript and return the result.
1044    pub async fn eval<T: serde::de::DeserializeOwned>(&self, js: &str) -> Result<T> {
1045        self.page.evaluate(js).await
1046    }
1047
1048    /// Execute JavaScript (no return value).
1049    pub async fn exec(&self, js: &str) -> Result<()> {
1050        self.page.execute(js).await
1051    }
1052
1053    // =========================================================================
1054    // SPA Navigation
1055    // =========================================================================
1056
1057    /// Detect the SPA router type and current route state.
1058    pub async fn spa_info(&self) -> Result<SpaRouterInfo> {
1059        spa::detect_router(&self.page).await
1060    }
1061
1062    /// Navigate the SPA to a new path without page reload.
1063    /// Automatically detects the router type and uses the appropriate navigation method.
1064    /// Clears element cache since the DOM will change.
1065    pub async fn spa_navigate(&mut self, path: &str) -> Result<String> {
1066        let info = spa::detect_router(&self.page).await?;
1067        let result = spa::spa_navigate(&self.page, &info.router_type, path).await?;
1068        self.elements.clear();
1069        Ok(result)
1070    }
1071
1072    /// Navigate browser history by delta steps.
1073    /// delta = -1 goes back, delta = 1 goes forward.
1074    /// Clears element cache since the DOM will change.
1075    pub async fn history_go(&mut self, delta: i32) -> Result<()> {
1076        spa::history_go(&self.page, delta).await?;
1077        self.elements.clear();
1078        Ok(())
1079    }
1080
1081    // =========================================================================
1082    // Cleanup
1083    // =========================================================================
1084
1085    /// Close the browser.
1086    pub async fn close(self) -> Result<()> {
1087        self.browser.close().await
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094
1095    fn make_element(
1096        index: usize,
1097        tag: &str,
1098        text: &str,
1099        role: Option<&str>,
1100        input_type: Option<&str>,
1101        placeholder: Option<&str>,
1102        value: Option<&str>,
1103        checked: bool,
1104    ) -> InteractiveElement {
1105        let selector = format!("[data-idx=\"{}\"]", index);
1106        let fingerprint = InteractiveElement::compute_fingerprint(
1107            tag,
1108            text,
1109            role,
1110            input_type,
1111            placeholder,
1112            &selector,
1113        );
1114        InteractiveElement {
1115            index,
1116            tag: tag.to_string(),
1117            text: text.to_string(),
1118            role: role.map(|s| s.to_string()),
1119            input_type: input_type.map(|s| s.to_string()),
1120            placeholder: placeholder.map(|s| s.to_string()),
1121            value: value.map(|s| s.to_string()),
1122            checked,
1123            selector,
1124            bbox: BoundingBox {
1125                x: 0.0,
1126                y: 0.0,
1127                width: 100.0,
1128                height: 30.0,
1129            },
1130            fingerprint,
1131        }
1132    }
1133
1134    #[test]
1135    fn test_element_display_basic() {
1136        let el = make_element(0, "button", "Submit", None, None, None, None, false);
1137        assert_eq!(el.to_string(), "[0] <button> \"Submit\"");
1138    }
1139
1140    #[test]
1141    fn test_element_display_with_input_type() {
1142        // text type is suppressed
1143        let el = make_element(0, "input", "", None, Some("text"), None, None, false);
1144        assert_eq!(el.to_string(), "[0] <input>");
1145
1146        // other types are shown
1147        let el = make_element(0, "input", "", None, Some("password"), None, None, false);
1148        assert_eq!(el.to_string(), "[0] <input type=\"password\">");
1149    }
1150
1151    #[test]
1152    fn test_element_display_with_placeholder() {
1153        let el = make_element(
1154            0,
1155            "input",
1156            "",
1157            None,
1158            Some("text"),
1159            Some("Enter email"),
1160            None,
1161            false,
1162        );
1163        assert_eq!(el.to_string(), "[0] <input> placeholder=\"Enter email\"");
1164    }
1165
1166    #[test]
1167    fn test_element_display_with_value() {
1168        let el = make_element(
1169            0,
1170            "input",
1171            "",
1172            None,
1173            Some("text"),
1174            None,
1175            Some("hello"),
1176            false,
1177        );
1178        assert_eq!(el.to_string(), "[0] <input> value=\"hello\"");
1179    }
1180
1181    #[test]
1182    fn test_element_display_checked() {
1183        let el = make_element(0, "input", "", None, Some("checkbox"), None, None, true);
1184        assert_eq!(el.to_string(), "[0] <input type=\"checkbox\"> [checked]");
1185    }
1186
1187    #[test]
1188    fn test_element_display_redundant_role_suppressed() {
1189        // button role on button tag is redundant
1190        let el = make_element(
1191            0,
1192            "button",
1193            "Click",
1194            Some("button"),
1195            None,
1196            None,
1197            None,
1198            false,
1199        );
1200        assert_eq!(el.to_string(), "[0] <button> \"Click\"");
1201
1202        // link role on a tag is redundant
1203        let el = make_element(0, "a", "Link", Some("link"), None, None, None, false);
1204        assert_eq!(el.to_string(), "[0] <a> \"Link\"");
1205
1206        // menuitem role on a tag is redundant
1207        let el = make_element(0, "a", "Menu", Some("menuitem"), None, None, None, false);
1208        assert_eq!(el.to_string(), "[0] <a> \"Menu\"");
1209    }
1210
1211    #[test]
1212    fn test_element_display_non_redundant_role_shown() {
1213        // tab role on button is meaningful
1214        let el = make_element(0, "button", "Tab 1", Some("tab"), None, None, None, false);
1215        assert_eq!(el.to_string(), "[0] <button> \"Tab 1\" role=\"tab\"");
1216
1217        // button role on div is meaningful
1218        let el = make_element(0, "div", "Click", Some("button"), None, None, None, false);
1219        assert_eq!(el.to_string(), "[0] <div> \"Click\" role=\"button\"");
1220    }
1221
1222    #[test]
1223    fn test_observe_diff_display_no_changes() {
1224        let diff = ObserveDiff {
1225            added: vec![],
1226            removed: 0,
1227            total: 5,
1228        };
1229        assert_eq!(diff.to_string(), "no changes (5 elements)");
1230    }
1231
1232    #[test]
1233    fn test_observe_diff_display_added_only() {
1234        let diff = ObserveDiff {
1235            added: vec![5, 6],
1236            removed: 0,
1237            total: 7,
1238        };
1239        assert_eq!(diff.to_string(), "+2 added (7 total)");
1240    }
1241
1242    #[test]
1243    fn test_observe_diff_display_removed_only() {
1244        let diff = ObserveDiff {
1245            added: vec![],
1246            removed: 3,
1247            total: 2,
1248        };
1249        assert_eq!(diff.to_string(), "-3 removed (2 total)");
1250    }
1251
1252    #[test]
1253    fn test_observe_diff_display_both() {
1254        let diff = ObserveDiff {
1255            added: vec![3, 4],
1256            removed: 1,
1257            total: 5,
1258        };
1259        assert_eq!(diff.to_string(), "+2 added, -1 removed (5 total)");
1260    }
1261
1262    #[test]
1263    fn test_observe_config_default() {
1264        let config = ObserveConfig::default();
1265        assert!(config.viewport_only);
1266    }
1267}