Skip to main content

eoka/
page.rs

1//! Page Abstraction
2//!
3//! High-level API for interacting with a browser page.
4
5use std::collections::HashMap;
6use std::sync::Arc;
7
8use crate::cdp::{Cookie, MouseButton, MouseEventType, Session};
9use crate::error::{Error, Result};
10use crate::stealth::Human;
11use crate::StealthConfig;
12
13/// Polling interval (ms) used by wait_for_* loop iterations.
14const POLL_INTERVAL_MS: u64 = 100;
15
16/// Settle time (ms) used after actions (click, navigate, hover) to let the
17/// page react before the next step.
18const SETTLE_MS: u64 = 100;
19
20/// Brief pause (ms) between micro-interactions (e.g. focus→type, select_all→delete).
21const INTERACTION_DELAY_MS: u64 = 50;
22
23/// Async sleep for the given number of milliseconds.
24async fn sleep_ms(ms: u64) {
25    tokio::time::sleep(std::time::Duration::from_millis(ms)).await;
26}
27
28/// Escape a string for safe use in JavaScript string literals (single pass)
29fn escape_js_string(s: &str) -> String {
30    let mut out = String::with_capacity(s.len());
31    let mut chars = s.chars().peekable();
32    while let Some(ch) = chars.next() {
33        match ch {
34            '\\' => out.push_str("\\\\"),
35            '\'' => out.push_str("\\'"),
36            '"' => out.push_str("\\\""),
37            '`' => out.push_str("\\`"),
38            '\n' => out.push_str("\\n"),
39            '\r' => out.push_str("\\r"),
40            '\0' => out.push_str("\\0"),
41            '\u{2028}' => out.push_str("\\u2028"),
42            '\u{2029}' => out.push_str("\\u2029"),
43            '$' if chars.peek() == Some(&'{') => {
44                out.push_str("\\${");
45                chars.next();
46            }
47            _ => out.push(ch),
48        }
49    }
50    out
51}
52
53/// Check if a CDP error is an element-related error (not found, not visible, etc.)
54fn is_element_cdp_error(e: &Error) -> bool {
55    match e {
56        Error::ElementNotFound(_) | Error::ElementNotVisible { .. } => true,
57        Error::Cdp { message, .. } => {
58            message.contains("box model")
59                || message.contains("Could not find node")
60                || message.contains("No node with given id")
61                || message.contains("Node is not an element")
62        }
63        _ => false,
64    }
65}
66/// Text matching strategy for find_by_text operations
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68pub enum TextMatch {
69    /// Exact match (trimmed, case-sensitive)
70    Exact,
71    /// Contains the text (case-insensitive) - default
72    #[default]
73    Contains,
74    /// Starts with the text (case-insensitive)
75    StartsWith,
76    /// Ends with the text (case-insensitive)
77    EndsWith,
78}
79
80/// A browser page with stealth capabilities
81pub struct Page {
82    session: Session,
83    config: Arc<StealthConfig>,
84}
85
86impl Page {
87    /// Create a new Page wrapping a CDP session
88    pub(crate) fn new(session: Session, config: Arc<StealthConfig>) -> Self {
89        Self { session, config }
90    }
91
92    /// Get the underlying CDP session
93    pub fn session(&self) -> &Session {
94        &self.session
95    }
96
97    /// Get the target ID for this page (tab identifier)
98    pub fn target_id(&self) -> &str {
99        self.session.target_id()
100    }
101
102    /// Check a navigation result for errors.
103    /// ERR_HTTP_RESPONSE_CODE_FAILURE (4xx/5xx) is ignored — the page still loaded.
104    fn check_nav_result(result: &crate::cdp::types::PageNavigateResult) -> Result<()> {
105        if let Some(ref error) = result.error_text {
106            if error != "net::ERR_HTTP_RESPONSE_CODE_FAILURE" {
107                return Err(Error::Navigation(error.clone()));
108            }
109        }
110        Ok(())
111    }
112
113    /// Resolve the current page URL for cookie operations.
114    /// Returns None for about:blank (Chrome silently rejects cookies without a URL).
115    async fn cookie_url(&self) -> Result<Option<String>> {
116        let url = self.url().await?;
117        Ok(if url == "about:blank" { None } else { Some(url) })
118    }
119
120    /// Navigate to a URL
121    pub async fn goto(&self, url: &str) -> Result<()> {
122        self.navigate_impl(url, None).await
123    }
124
125    /// Reload the page
126    pub async fn reload(&self) -> Result<()> {
127        self.session.reload(false).await
128    }
129
130    /// Go back in history
131    pub async fn back(&self) -> Result<()> {
132        self.session.go_back().await
133    }
134
135    /// Go forward in history
136    pub async fn forward(&self) -> Result<()> {
137        self.session.go_forward().await
138    }
139    /// Get current URL
140    pub async fn url(&self) -> Result<String> {
141        let frame_tree = self.session.get_frame_tree().await?;
142        Ok(frame_tree.frame.url)
143    }
144
145    /// Get page title
146    pub async fn title(&self) -> Result<String> {
147        // Use evaluate_sync (awaitPromise=false) so heavy SPAs don't block
148        self.evaluate_sync("document.title || ''").await
149    }
150
151    /// Get page HTML content
152    pub async fn content(&self) -> Result<String> {
153        self.evaluate_sync("document.documentElement.outerHTML")
154            .await
155    }
156
157    /// Get page text content (body innerText)
158    pub async fn text(&self) -> Result<String> {
159        self.evaluate_sync("document.body?.innerText || ''").await
160    }
161    /// Capture a screenshot as PNG bytes
162    pub async fn screenshot(&self) -> Result<Vec<u8>> {
163        self.session.capture_screenshot(Some("png"), None).await
164    }
165
166    /// Capture a screenshot as JPEG with quality
167    pub async fn screenshot_jpeg(&self, quality: u8) -> Result<Vec<u8>> {
168        self.session
169            .capture_screenshot(Some("jpeg"), Some(quality))
170            .await
171    }
172    /// Find an element by CSS selector
173    pub async fn find(&self, selector: &str) -> Result<Element<'_>> {
174        let doc = self.session.get_document(Some(0)).await?;
175        let node_id = self.session.query_selector(doc.node_id, selector).await?;
176
177        if node_id == 0 {
178            return Err(Error::ElementNotFound(selector.to_string()));
179        }
180
181        Ok(Element {
182            page: self,
183            node_id,
184        })
185    }
186
187    /// Find all elements matching a CSS selector
188    pub async fn find_all(&self, selector: &str) -> Result<Vec<Element<'_>>> {
189        let doc = self.session.get_document(Some(0)).await?;
190        let node_ids = self
191            .session
192            .query_selector_all(doc.node_id, selector)
193            .await?;
194
195        Ok(node_ids
196            .into_iter()
197            .filter(|&id| id != 0)
198            .map(|node_id| Element {
199                page: self,
200                node_id,
201            })
202            .collect())
203    }
204
205    /// Check if an element exists
206    #[must_use = "returns true if element exists"]
207    pub async fn exists(&self, selector: &str) -> bool {
208        self.find(selector).await.is_ok()
209    }
210    /// Find an element by its text content (case-insensitive contains)
211    pub async fn find_by_text(&self, text: &str) -> Result<Element<'_>> {
212        self.find_by_text_match(text, TextMatch::Contains).await
213    }
214
215    /// Find an element by text with specific matching strategy
216    ///
217    /// Prioritizes interactive elements (a, button, input) over static elements.
218    /// Uses Runtime.callFunctionOn to avoid mutating the DOM (no marker attributes).
219    pub async fn find_by_text_match(
220        &self,
221        text: &str,
222        match_type: TextMatch,
223    ) -> Result<Element<'_>> {
224        let escaped_text = escape_js_string(text);
225        let match_js = match match_type {
226            TextMatch::Exact => format!("t.trim() === '{}'", escaped_text),
227            TextMatch::Contains => format!(
228                "t.toLowerCase().includes('{}')",
229                escaped_text.to_lowercase()
230            ),
231            TextMatch::StartsWith => format!(
232                "t.toLowerCase().startsWith('{}')",
233                escaped_text.to_lowercase()
234            ),
235            TextMatch::EndsWith => format!(
236                "t.toLowerCase().endsWith('{}')",
237                escaped_text.to_lowercase()
238            ),
239        };
240
241        let js = format!(
242            r#"
243            (() => {{
244                const interactive = 'a, button, input[type="submit"], input[type="button"], [role="button"], [onclick]';
245                for (const el of document.querySelectorAll(interactive)) {{
246                    const t = el.innerText || el.textContent || el.value || '';
247                    if ({match_js}) return el;
248                }}
249                const secondary = 'label, span, div, p, h1, h2, h3, h4, h5, h6, li, td, th';
250                for (const el of document.querySelectorAll(secondary)) {{
251                    const t = el.innerText || el.textContent || el.value || '';
252                    if ({match_js}) return el;
253                }}
254                return null;
255            }})()
256            "#,
257        );
258
259        let result = self.session.evaluate_for_remote_object(&js).await?;
260        let remote = self.check_js_result(result)?;
261
262        if remote.subtype.as_deref() == Some("null") {
263            return Err(Error::ElementNotFound(format!("text: {}", text)));
264        }
265
266        let object_id = remote
267            .object_id
268            .ok_or_else(|| Error::ElementNotFound(format!("text: {}", text)))?;
269
270        // Convert remote object to DOM node_id
271        let node_id = self.session.request_node(&object_id).await?;
272
273        if node_id == 0 {
274            return Err(Error::ElementNotFound(format!("text: {}", text)));
275        }
276
277        Ok(Element {
278            page: self,
279            node_id,
280        })
281    }
282
283    /// Find all elements matching the given text
284    pub async fn find_all_by_text(&self, text: &str) -> Result<Vec<Element<'_>>> {
285        let escaped_text = escape_js_string(text).to_lowercase();
286
287        let js = format!(
288            r#"
289            (() => {{
290                const selectors = 'a, button, input, label, span, div, p, h1, h2, h3, h4, h5, h6, li, td, th';
291                const elements = document.querySelectorAll(selectors);
292                const matches = [];
293                for (const el of elements) {{
294                    const t = (el.innerText || el.textContent || el.value || '').toLowerCase();
295                    if (t.includes('{escaped_text}')) {{
296                        matches.push(el);
297                    }}
298                }}
299                return matches;
300            }})()
301            "#,
302        );
303
304        // Evaluate without returnByValue to get remote object references
305        let result = self.session.evaluate_for_remote_object(&js).await?;
306
307        let remote = self.check_js_result(result)?;
308
309        let array_object_id = match &remote.object_id {
310            Some(id) => id.clone(),
311            None => return Ok(Vec::new()),
312        };
313
314        // Get all indexed properties of the array in one CDP call
315        let properties = self.session.get_properties(&array_object_id).await?;
316
317        let mut elements = Vec::new();
318        for prop in &properties {
319            // Array elements have numeric names; skip "length" and prototype props
320            if prop.name.parse::<usize>().is_err() {
321                continue;
322            }
323            if let Some(ref obj_id) = prop.value.as_ref().and_then(|v| v.object_id.clone()) {
324                if let Ok(node_id) = self.session.request_node(obj_id).await {
325                    if node_id != 0 {
326                        elements.push(Element {
327                            page: self,
328                            node_id,
329                        });
330                    }
331                }
332            }
333        }
334
335        Ok(elements)
336    }
337
338    /// Check if an element with the given text exists
339    #[must_use = "returns true if text exists on page"]
340    pub async fn text_exists(&self, text: &str) -> bool {
341        self.find_by_text(text).await.is_ok()
342    }
343    /// Click at coordinates
344    pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
345        // Mouse down
346        self.session
347            .dispatch_mouse_event(
348                MouseEventType::MousePressed,
349                x,
350                y,
351                Some(MouseButton::Left),
352                Some(1),
353            )
354            .await?;
355
356        sleep_ms(INTERACTION_DELAY_MS).await;
357
358        // Mouse up
359        self.session
360            .dispatch_mouse_event(
361                MouseEventType::MouseReleased,
362                x,
363                y,
364                Some(MouseButton::Left),
365                Some(1),
366            )
367            .await?;
368
369        Ok(())
370    }
371
372    /// Click on an element by selector
373    pub async fn click(&self, selector: &str) -> Result<()> {
374        let element = self.find(selector).await?;
375        element.click().await
376    }
377
378    /// Type text into focused element
379    pub async fn type_text(&self, text: &str) -> Result<()> {
380        self.session.insert_text(text).await
381    }
382
383    /// Type text into an element by selector
384    pub async fn type_into(&self, selector: &str, text: &str) -> Result<()> {
385        let element = self.find(selector).await?;
386        element.click().await?;
387        sleep_ms(INTERACTION_DELAY_MS).await;
388        self.session.insert_text(text).await
389    }
390
391    /// Click an element by its text content
392    pub async fn click_by_text(&self, text: &str) -> Result<()> {
393        let element = self.find_by_text(text).await?;
394        element.click().await
395    }
396
397    /// Try to click an element, returning Ok(false) if not found or not clickable
398    #[must_use = "returns true if clicked, false if not found/visible"]
399    pub async fn try_click(&self, selector: &str) -> Result<bool> {
400        self.try_click_impl(self.find(selector).await).await
401    }
402
403    /// Try to click an element by text, returning Ok(false) if not found or not clickable
404    #[must_use = "returns true if clicked, false if not found/visible"]
405    pub async fn try_click_by_text(&self, text: &str) -> Result<bool> {
406        self.try_click_impl(self.find_by_text(text).await).await
407    }
408
409    /// Shared impl for try_click and try_click_by_text
410    async fn try_click_impl(&self, find_result: Result<Element<'_>>) -> Result<bool> {
411        match find_result {
412            Ok(element) => match element.click().await {
413                Ok(()) => Ok(true),
414                Err(e) if is_element_cdp_error(&e) => Ok(false),
415                Err(e) => Err(e),
416            },
417            Err(e) if is_element_cdp_error(&e) => Ok(false),
418            Err(e) => Err(e),
419        }
420    }
421
422    /// Fill a form field: click, clear, type
423    pub async fn fill(&self, selector: &str, value: &str) -> Result<()> {
424        let element = self.find(selector).await?;
425        element.click().await?;
426        sleep_ms(INTERACTION_DELAY_MS).await;
427
428        // Focus + select via selector (don't rely on activeElement — popups can steal focus)
429        let escaped = escape_js_string(selector);
430        self.execute(&format!(
431            "(() => {{ const el = document.querySelector('{}'); if (el) {{ el.focus(); el.select(); }} }})()",
432            escaped
433        )).await?;
434        self.session.insert_text("").await?;
435
436        // Now type the new value
437        self.session.insert_text(value).await
438    }
439    /// Get a Human helper for human-like interactions
440    pub fn human(&self) -> Human<'_> {
441        Human::new(&self.session)
442    }
443
444    /// Human-like click on an element
445    pub async fn human_click(&self, selector: &str) -> Result<()> {
446        let element = self.find(selector).await?;
447        let (x, y) = element.center().await?;
448        self.human_click_at_center_xy(x, y).await
449    }
450
451    /// Human-like typing into an element
452    pub async fn human_type(&self, selector: &str, text: &str) -> Result<()> {
453        self.human_click(selector).await?;
454        sleep_ms(SETTLE_MS).await;
455        self.human_type_text(text).await
456    }
457
458    /// Human-like click on an element found by text content
459    pub async fn human_click_by_text(&self, text: &str) -> Result<()> {
460        let element = self.find_by_text(text).await?;
461        let (x, y) = element.center().await?;
462        self.human_click_at_center_xy(x, y).await
463    }
464
465    /// Try to human-click an element, returning Ok(true) if clicked, Ok(false) if not found or not clickable
466    #[must_use = "returns true if clicked, false if not found/visible"]
467    pub async fn try_human_click(&self, selector: &str) -> Result<bool> {
468        self.try_human_click_impl(self.find(selector).await).await
469    }
470
471    /// Try to human-click an element by text, returning Ok(true) if clicked, Ok(false) if not found or not clickable
472    #[must_use = "returns true if clicked, false if not found/visible"]
473    pub async fn try_human_click_by_text(&self, text: &str) -> Result<bool> {
474        self.try_human_click_impl(self.find_by_text(text).await)
475            .await
476    }
477
478    /// Human-like form fill: click, clear, type with natural delays
479    pub async fn human_fill(&self, selector: &str, value: &str) -> Result<()> {
480        self.human_click(selector).await?;
481        sleep_ms(SETTLE_MS).await;
482        self.execute("document.activeElement.select()").await?;
483        sleep_ms(INTERACTION_DELAY_MS).await;
484        self.human_type_text(value).await
485    }
486
487    /// Type text, using human-like timing if configured
488    async fn human_type_text(&self, text: &str) -> Result<()> {
489        if self.config.human_typing {
490            self.human().type_text(text).await
491        } else {
492            self.session.insert_text(text).await
493        }
494    }
495
496    async fn human_click_at_center_xy(&self, x: f64, y: f64) -> Result<()> {
497        if self.config.human_mouse {
498            self.human().move_and_click(x, y).await
499        } else {
500            self.click_at(x, y).await
501        }
502    }
503
504    /// Shared impl for try_human_click and try_human_click_by_text
505    async fn try_human_click_impl(&self, find_result: Result<Element<'_>>) -> Result<bool> {
506        match find_result {
507            Ok(element) => match element.center().await {
508                Ok((x, y)) => {
509                    self.human_click_at_center_xy(x, y).await?;
510                    Ok(true)
511                }
512                Err(e) if is_element_cdp_error(&e) => Ok(false),
513                Err(e) => Err(e),
514            },
515            Err(e) if is_element_cdp_error(&e) => Ok(false),
516            Err(e) => Err(e),
517        }
518    }
519    /// Evaluate JavaScript and return the result
520    pub async fn evaluate<T: serde::de::DeserializeOwned>(&self, expression: &str) -> Result<T> {
521        self.eval_impl(self.session.evaluate(expression).await?)
522    }
523
524    /// Evaluate JavaScript synchronously (don't await promises).
525    /// Use when the page may have unresolved promises that block normal evaluate.
526    pub async fn evaluate_sync<T: serde::de::DeserializeOwned>(
527        &self,
528        expression: &str,
529    ) -> Result<T> {
530        self.eval_impl(self.session.evaluate_sync(expression).await?)
531    }
532
533    /// Shared impl: check for exceptions and extract the value
534    fn eval_impl<T: serde::de::DeserializeOwned>(
535        &self,
536        result: crate::cdp::types::RuntimeEvaluateResult,
537    ) -> Result<T> {
538        let remote = self.check_js_result(result)?;
539        let value = remote
540            .value
541            .ok_or_else(|| Error::CdpSimple("No value returned from evaluate".into()))?;
542        Ok(serde_json::from_value(value)?)
543    }
544
545    /// Execute JavaScript without expecting a return value
546    pub async fn execute(&self, expression: &str) -> Result<()> {
547        self.check_js_result(self.session.evaluate(expression).await?)?;
548        Ok(())
549    }
550
551    /// Execute JavaScript synchronously (don't await promises)
552    pub async fn execute_sync(&self, expression: &str) -> Result<()> {
553        self.check_js_result(self.session.evaluate_sync(expression).await?)?;
554        Ok(())
555    }
556
557    /// Check a JS evaluation result for exceptions
558    fn check_js_result(
559        &self,
560        result: crate::cdp::types::RuntimeEvaluateResult,
561    ) -> Result<crate::cdp::types::RemoteObject> {
562        if let Some(exception) = result.exception_details {
563            return Err(Error::CdpSimple(format!(
564                "JavaScript error: {} at {}:{}",
565                exception.text, exception.line_number, exception.column_number
566            )));
567        }
568        Ok(result.result)
569    }
570    /// Get all cookies
571    pub async fn cookies(&self) -> Result<Vec<Cookie>> {
572        self.session.get_cookies(None).await
573    }
574
575    /// Set a cookie
576    pub async fn set_cookie(
577        &self,
578        name: &str,
579        value: &str,
580        domain: Option<&str>,
581        path: Option<&str>,
582    ) -> Result<()> {
583        let url = self.cookie_url().await?;
584        let success = self
585            .session
586            .set_cookie(name, value, url.as_deref(), domain, path)
587            .await?;
588        if !success {
589            return Err(Error::CdpSimple("Failed to set cookie".into()));
590        }
591        Ok(())
592    }
593
594    /// Delete a cookie
595    pub async fn delete_cookie(&self, name: &str, domain: Option<&str>) -> Result<()> {
596        let url = self.cookie_url().await?;
597        self.session
598            .delete_cookies(name, url.as_deref(), domain)
599            .await
600    }
601
602    /// Clear all browser cookies for this context.
603    pub async fn clear_all_cookies(&self) -> Result<()> {
604        self.session.clear_all_cookies().await
605    }
606
607    /// Bulk-import cookies (e.g., restored from a prior dump_storage call).
608    pub async fn set_cookies_bulk(
609        &self,
610        cookies: Vec<crate::cdp::types::NetworkSetCookie>,
611    ) -> Result<()> {
612        self.session.set_cookies(cookies).await
613    }
614
615    /// Set extra HTTP headers sent with every subsequent request from this page.
616    /// Pass an empty map to clear.
617    pub async fn set_extra_headers(&self, headers: HashMap<String, String>) -> Result<()> {
618        self.session.set_extra_headers(headers).await
619    }
620
621    /// Remove all extra HTTP headers set via set_extra_headers.
622    pub async fn clear_extra_headers(&self) -> Result<()> {
623        self.session.clear_extra_headers().await
624    }
625
626    /// Navigate to `url` while sending custom HTTP headers.
627    /// Headers are set before navigation and cleared afterward.
628    pub async fn goto_with_headers(
629        &self,
630        url: &str,
631        headers: HashMap<String, String>,
632    ) -> Result<()> {
633        self.session.set_extra_headers(headers).await?;
634        let result = self.goto(url).await;
635        let _ = self.session.clear_extra_headers().await;
636        result
637    }
638
639    /// Navigate to `url` with a custom Referer header.
640    pub async fn goto_with_referrer(&self, url: &str, referrer: &str) -> Result<()> {
641        self.navigate_impl(url, Some(referrer)).await
642    }
643
644    /// Shared navigation impl
645    async fn navigate_impl(&self, url: &str, referrer: Option<&str>) -> Result<()> {
646        let result = self.session.navigate(url, referrer).await?;
647        Self::check_nav_result(&result)?;
648        sleep_ms(SETTLE_MS).await;
649        Ok(())
650    }
651
652    /// Disable CSP enforcement for the current page.
653    /// Must be called before navigation to take effect.
654    pub async fn set_bypass_csp(&self, enabled: bool) -> Result<()> {
655        self.session.set_bypass_csp(enabled).await
656    }
657
658    /// Override the User-Agent string for this page.
659    pub async fn set_user_agent(&self, user_agent: &str) -> Result<()> {
660        self.session.set_user_agent(user_agent, None).await
661    }
662
663    /// Ignore TLS certificate errors for this session.
664    pub async fn ignore_cert_errors(&self, ignore: bool) -> Result<()> {
665        self.session.set_ignore_cert_errors(ignore).await
666    }
667
668    /// Accept a pending JS dialog (alert / confirm / prompt).
669    /// For prompt() dialogs, provide `prompt_text` to fill the input.
670    pub async fn accept_dialog(&self, prompt_text: Option<&str>) -> Result<()> {
671        self.session.handle_dialog(true, prompt_text).await
672    }
673
674    /// Dismiss a pending JS dialog (cancel / close).
675    pub async fn dismiss_dialog(&self) -> Result<()> {
676        self.session.handle_dialog(false, None).await
677    }
678
679    /// Generic polling helper — calls `check` every `POLL_INTERVAL_MS` until it
680    /// returns `Ok(Some(value))`, then returns that value.  Returns `Err(Timeout)`
681    /// if `timeout_ms` elapses first.
682    async fn poll_until<T, F, Fut>(&self, timeout_ms: u64, error_msg: String, check: F) -> Result<T>
683    where
684        F: Fn() -> Fut,
685        Fut: std::future::Future<Output = Option<T>>,
686    {
687        let start = std::time::Instant::now();
688        let timeout = std::time::Duration::from_millis(timeout_ms);
689        loop {
690            if let Some(val) = check().await {
691                return Ok(val);
692            }
693            if start.elapsed() > timeout {
694                return Err(Error::Timeout(error_msg));
695            }
696            sleep_ms(POLL_INTERVAL_MS).await;
697        }
698    }
699
700    /// Wait for an element to appear in the DOM
701    pub async fn wait_for(&self, selector: &str, timeout_ms: u64) -> Result<Element<'_>> {
702        self.poll_until(
703            timeout_ms,
704            format!("Element '{}' not found within {}ms", selector, timeout_ms),
705            || async { self.find(selector).await.ok() },
706        )
707        .await
708    }
709
710    /// Wait for an element to be visible and clickable
711    pub async fn wait_for_visible(&self, selector: &str, timeout_ms: u64) -> Result<Element<'_>> {
712        self.poll_until(
713            timeout_ms,
714            format!("Element '{}' not visible within {}ms", selector, timeout_ms),
715            || async {
716                if let Ok(elem) = self.find(selector).await {
717                    if elem.center().await.is_ok() {
718                        return Some(elem);
719                    }
720                }
721                None
722            },
723        )
724        .await
725    }
726
727    /// Wait for an element to disappear
728    pub async fn wait_for_hidden(&self, selector: &str, timeout_ms: u64) -> Result<()> {
729        self.poll_until(
730            timeout_ms,
731            format!(
732                "Element '{}' still visible after {}ms",
733                selector, timeout_ms
734            ),
735            || async { self.find(selector).await.is_err().then_some(()) },
736        )
737        .await
738    }
739
740    /// Wait for a fixed duration
741    pub async fn wait(&self, ms: u64) {
742        sleep_ms(ms).await;
743    }
744
745    /// Wait for an element with specific text to appear
746    pub async fn wait_for_text(&self, text: &str, timeout_ms: u64) -> Result<Element<'_>> {
747        self.poll_until(
748            timeout_ms,
749            format!(
750                "Element with text '{}' not found within {}ms",
751                text, timeout_ms
752            ),
753            || async { self.find_by_text(text).await.ok() },
754        )
755        .await
756    }
757
758    /// Wait for the URL to contain a specific string
759    pub async fn wait_for_url_contains(&self, pattern: &str, timeout_ms: u64) -> Result<()> {
760        self.poll_until(
761            timeout_ms,
762            format!("URL did not contain '{}' within {}ms", pattern, timeout_ms),
763            || async {
764                if let Ok(url) = self.url().await {
765                    if url.contains(pattern) {
766                        return Some(());
767                    }
768                }
769                None
770            },
771        )
772        .await
773    }
774
775    /// Wait for URL to change from current URL
776    pub async fn wait_for_url_change(&self, timeout_ms: u64) -> Result<String> {
777        let original_url = self.url().await?;
778        self.poll_until(
779            timeout_ms,
780            format!(
781                "URL did not change from '{}' within {}ms",
782                original_url, timeout_ms
783            ),
784            || async {
785                if let Ok(url) = self.url().await {
786                    if url != original_url {
787                        return Some(url);
788                    }
789                }
790                None
791            },
792        )
793        .await
794    }
795    /// Enable network request capture
796    /// NOTE: This enables Network.enable which may be slightly detectable by advanced anti-bot
797    pub async fn enable_request_capture(&self) -> Result<()> {
798        self.session.network_enable().await
799    }
800
801    /// Disable network request capture
802    pub async fn disable_request_capture(&self) -> Result<()> {
803        self.session.network_disable().await
804    }
805
806    /// Get response body for a captured request
807    /// The request_id comes from CapturedRequest.request_id
808    pub async fn get_response_body(&self, request_id: &str) -> Result<ResponseBody> {
809        let (body, base64_encoded) = self.session.get_response_body(request_id).await?;
810
811        if base64_encoded {
812            use base64::Engine;
813            let bytes = base64::engine::general_purpose::STANDARD
814                .decode(&body)
815                .map_err(|e| Error::Decode(e.to_string()))?;
816            Ok(ResponseBody::Binary(bytes))
817        } else {
818            Ok(ResponseBody::Text(body))
819        }
820    }
821    /// Find the first element matching any of the given selectors
822    pub async fn find_any(&self, selectors: &[&str]) -> Result<Element<'_>> {
823        for selector in selectors {
824            if let Ok(element) = self.find(selector).await {
825                return Ok(element);
826            }
827        }
828        Err(Error::ElementNotFound(format!(
829            "None of selectors found: {:?}",
830            selectors
831        )))
832    }
833
834    /// Wait for any of the given selectors to appear
835    ///
836    /// Returns the first selector that matches.
837    pub async fn wait_for_any(&self, selectors: &[&str], timeout_ms: u64) -> Result<Element<'_>> {
838        self.poll_until(
839            timeout_ms,
840            format!(
841                "None of selectors found within {}ms: {:?}",
842                timeout_ms, selectors
843            ),
844            || async { self.find_any(selectors).await.ok() },
845        )
846        .await
847    }
848    /// Wait for network to become idle (no pending XHR/fetch for `idle_time_ms`)
849    pub async fn wait_for_network_idle(&self, idle_time_ms: u64, timeout_ms: u64) -> Result<()> {
850        let start = std::time::Instant::now();
851        let timeout = std::time::Duration::from_millis(timeout_ms);
852        let idle_duration = std::time::Duration::from_millis(idle_time_ms);
853
854        // Use JavaScript to monitor network activity
855        let check_idle_js = r#"
856            (() => {
857                // Check if there are pending fetches/XHRs
858                if (window.__eoka_pending_requests === undefined) {
859                    window.__eoka_pending_requests = 0;
860
861                    // Intercept fetch
862                    const originalFetch = window.fetch;
863                    window.fetch = function(...args) {
864                        window.__eoka_pending_requests++;
865                        return originalFetch.apply(this, args).finally(() => {
866                            window.__eoka_pending_requests--;
867                        });
868                    };
869
870                    // Intercept XHR
871                    const originalOpen = XMLHttpRequest.prototype.open;
872                    const originalSend = XMLHttpRequest.prototype.send;
873                    XMLHttpRequest.prototype.open = function(...args) {
874                        this.__eoka_tracked = true;
875                        return originalOpen.apply(this, args);
876                    };
877                    XMLHttpRequest.prototype.send = function(...args) {
878                        if (this.__eoka_tracked) {
879                            window.__eoka_pending_requests++;
880                            this.addEventListener('loadend', () => {
881                                window.__eoka_pending_requests--;
882                            });
883                        }
884                        return originalSend.apply(this, args);
885                    };
886                }
887                return window.__eoka_pending_requests;
888            })()
889        "#;
890
891        // Install the interceptors (use evaluate_sync to avoid blocking on busy JS thread).
892        // Re-install on every poll iteration since full-page navigations destroy the
893        // previous window context along with our __eoka_pending_requests counter.
894        let _: i32 = self.evaluate_sync(check_idle_js).await.unwrap_or(0);
895
896        let mut idle_start: Option<std::time::Instant> = None;
897
898        loop {
899            // Re-install interceptors if the page navigated (counter will be undefined
900            // on the new document). This is cheap — the guard inside check_idle_js
901            // skips setup when __eoka_pending_requests is already defined.
902            let pending: i32 = self.evaluate_sync(check_idle_js).await.unwrap_or(0);
903
904            if pending == 0 {
905                match idle_start {
906                    Some(start) if start.elapsed() >= idle_duration => {
907                        return Ok(());
908                    }
909                    None => {
910                        idle_start = Some(std::time::Instant::now());
911                    }
912                    _ => {}
913                }
914            } else {
915                idle_start = None;
916            }
917
918            if start.elapsed() > timeout {
919                tracing::warn!(
920                    "wait_for_network_idle timed out after {}ms with {} pending request(s)",
921                    timeout_ms,
922                    pending
923                );
924                return Err(Error::Timeout(format!(
925                    "Network not idle after {}ms ({} pending requests)",
926                    timeout_ms, pending
927                )));
928            }
929
930            sleep_ms(INTERACTION_DELAY_MS).await;
931        }
932    }
933    /// Get a list of all frames on the page
934    pub async fn frames(&self) -> Result<Vec<FrameInfo>> {
935        let frame_tree = self.session.get_frame_tree().await?;
936        let mut frames = vec![FrameInfo {
937            id: frame_tree.frame.id.clone(),
938            url: frame_tree.frame.url.clone(),
939            name: frame_tree.frame.name.clone(),
940        }];
941
942        fn collect_frames(children: &[crate::cdp::types::FrameTree], frames: &mut Vec<FrameInfo>) {
943            for child in children {
944                frames.push(FrameInfo {
945                    id: child.frame.id.clone(),
946                    url: child.frame.url.clone(),
947                    name: child.frame.name.clone(),
948                });
949                collect_frames(&child.child_frames, frames);
950            }
951        }
952
953        collect_frames(&frame_tree.child_frames, &mut frames);
954        Ok(frames)
955    }
956
957    /// Execute JavaScript inside an iframe.
958    ///
959    /// # Safety
960    ///
961    /// `expression` is evaluated as **code** (via the `Function` constructor),
962    /// not as a string literal. Do not pass untrusted user input as the
963    /// expression — it will be executed in the iframe's JS context.
964    pub async fn evaluate_in_frame<T: serde::de::DeserializeOwned>(
965        &self,
966        frame_selector: &str,
967        expression: &str,
968    ) -> Result<T> {
969        let escaped_frame = escape_js_string(frame_selector);
970        let escaped_expr = escape_js_string(expression);
971
972        // Use Function constructor instead of eval (less likely to be blocked by CSP)
973        let js = format!(
974            r#"
975            (() => {{
976                const iframe = document.querySelector('{escaped_frame}');
977                if (!iframe || !iframe.contentWindow) throw new Error('Frame not found: {escaped_frame}');
978                const _exec = new iframe.contentWindow.Function('return (' + '{escaped_expr}' + ')');
979                return _exec.call(iframe.contentWindow);
980            }})()
981            "#,
982        );
983
984        self.evaluate(&js).await
985    }
986    /// Retry an operation multiple times with delays between attempts
987    pub async fn with_retry<F, Fut, T>(
988        &self,
989        attempts: u32,
990        delay_ms: u64,
991        operation: F,
992    ) -> Result<T>
993    where
994        F: Fn() -> Fut,
995        Fut: std::future::Future<Output = Result<T>>,
996    {
997        let mut last_error = String::new();
998
999        for attempt in 1..=attempts {
1000            match operation().await {
1001                Ok(result) => return Ok(result),
1002                Err(e) => {
1003                    last_error = e.to_string();
1004                    if attempt < attempts {
1005                        sleep_ms(delay_ms).await;
1006                    }
1007                }
1008            }
1009        }
1010
1011        Err(Error::RetryExhausted {
1012            attempts,
1013            last_error,
1014        })
1015    }
1016    /// Take a debug screenshot and save it with a timestamp
1017    ///
1018    /// Saves to `StealthConfig::debug_dir` if set, otherwise current directory.
1019    /// Useful during development to understand page state.
1020    pub async fn debug_screenshot(&self, prefix: &str) -> Result<String> {
1021        let timestamp = std::time::SystemTime::now()
1022            .duration_since(std::time::UNIX_EPOCH)
1023            .unwrap_or_default()
1024            .as_millis();
1025
1026        let filename = match &self.config.debug_dir {
1027            Some(dir) => {
1028                // Ensure directory exists
1029                std::fs::create_dir_all(dir)?;
1030                format!("{}/{}_{}.png", dir, prefix, timestamp)
1031            }
1032            None => format!("{}_{}.png", prefix, timestamp),
1033        };
1034
1035        let screenshot = self.screenshot().await?;
1036        std::fs::write(&filename, screenshot)?;
1037        Ok(filename)
1038    }
1039
1040    /// Log the current page state for debugging
1041    pub async fn debug_state(&self) -> Result<PageState> {
1042        let state: PageState = self
1043            .evaluate(
1044                r#"({
1045                url: location.href,
1046                title: document.title,
1047                input_count: document.querySelectorAll('input').length,
1048                button_count: document.querySelectorAll('button').length,
1049                link_count: document.querySelectorAll('a').length,
1050                form_count: document.querySelectorAll('form').length
1051            })"#,
1052            )
1053            .await
1054            .unwrap_or_else(|_| PageState {
1055                url: "unknown".to_string(),
1056                title: "unknown".to_string(),
1057                input_count: 0,
1058                button_count: 0,
1059                link_count: 0,
1060                form_count: 0,
1061            });
1062        Ok(state)
1063    }
1064
1065    /// Upload file(s) to a file input element
1066    pub async fn upload_file(&self, selector: &str, path: &str) -> Result<()> {
1067        self.upload_files(selector, &[path]).await
1068    }
1069
1070    /// Upload multiple files to a file input element
1071    pub async fn upload_files(&self, selector: &str, paths: &[&str]) -> Result<()> {
1072        let element = self.find(selector).await?;
1073        self.session
1074            .set_file_input_files(
1075                element.node_id,
1076                paths.iter().map(|p| p.to_string()).collect(),
1077            )
1078            .await
1079    }
1080
1081    /// Select option by value
1082    pub async fn select(&self, selector: &str, value: &str) -> Result<()> {
1083        let (sel, val) = (escape_js_string(selector), escape_js_string(value));
1084        self.execute(&format!(
1085            r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const opt=[...el.options].find(o=>o.value==='{val}');if(!opt)throw new Error('Option not found: {val}');el.value='{val}';el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
1086        )).await
1087    }
1088
1089    /// Select option by visible text
1090    pub async fn select_by_text(&self, selector: &str, text: &str) -> Result<()> {
1091        let (sel, txt) = (escape_js_string(selector), escape_js_string(text));
1092        self.execute(&format!(
1093            r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const opt=[...el.options].find(o=>o.text.trim()==='{txt}');if(!opt)throw new Error('Option not found: {txt}');el.value=opt.value;el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
1094        )).await
1095    }
1096
1097    /// Select multiple options by values
1098    pub async fn select_multiple(&self, selector: &str, values: &[&str]) -> Result<()> {
1099        let sel = escape_js_string(selector);
1100        let vals = serde_json::to_string(values).unwrap_or_else(|_| "[]".into());
1101        self.execute(&format!(
1102            r#"(()=>{{const el=document.querySelector('{sel}');if(!el)throw new Error('Select not found');const v={vals};for(const o of el.options)o.selected=v.includes(o.value);el.dispatchEvent(new Event('change',{{bubbles:true}}))}})()"#
1103        )).await
1104    }
1105
1106    /// Hover over element (for revealing menus)
1107    pub async fn hover(&self, selector: &str) -> Result<()> {
1108        let (x, y) = self.find(selector).await?.center().await?;
1109        self.session
1110            .dispatch_mouse_event(MouseEventType::MouseMoved, x, y, None, None)
1111            .await
1112    }
1113
1114    /// Human-like hover with Bezier curve movement
1115    pub async fn human_hover(&self, selector: &str) -> Result<()> {
1116        let element = self.find(selector).await?;
1117        element.scroll_into_view().await?;
1118        let (x, y) = element.center().await?;
1119        Human::new(&self.session).move_to(x, y).await?;
1120        sleep_ms(SETTLE_MS).await;
1121        Ok(())
1122    }
1123
1124    /// Press key with optional modifiers (e.g., "Enter", "Ctrl+A", "Cmd+Shift+S")
1125    pub async fn press_key(&self, key: &str) -> Result<()> {
1126        use crate::cdp::types::{InputDispatchKeyEventFull, KeyEventType};
1127
1128        let (mods, key_name) = parse_key_combo(key);
1129        let (key_str, code_str, vk) = key_to_codes(key_name);
1130        let modifiers = if mods != 0 { Some(mods) } else { None };
1131
1132        let make_event = |event_type| InputDispatchKeyEventFull {
1133            r#type: event_type,
1134            modifiers,
1135            key: Some(key_str.into()),
1136            code: Some(code_str.into()),
1137            windows_virtual_key_code: vk,
1138            native_virtual_key_code: vk,
1139            ..Default::default()
1140        };
1141
1142        self.session
1143            .dispatch_key_event_full(make_event(KeyEventType::KeyDown))
1144            .await?;
1145        sleep_ms(INTERACTION_DELAY_MS).await;
1146        self.session
1147            .dispatch_key_event_full(make_event(KeyEventType::KeyUp))
1148            .await
1149    }
1150
1151    /// Platform-aware select all (Cmd+A on Mac, Ctrl+A elsewhere)
1152    pub async fn select_all(&self) -> Result<()> {
1153        self.press_key(if cfg!(target_os = "macos") { "Cmd+A" } else { "Ctrl+A" }).await
1154    }
1155
1156    /// Platform-aware copy (Cmd+C on Mac, Ctrl+C elsewhere)
1157    pub async fn copy(&self) -> Result<()> {
1158        self.press_key(if cfg!(target_os = "macos") { "Cmd+C" } else { "Ctrl+C" }).await
1159    }
1160
1161    /// Platform-aware paste (Cmd+V on Mac, Ctrl+V elsewhere)
1162    pub async fn paste(&self) -> Result<()> {
1163        self.press_key(if cfg!(target_os = "macos") { "Cmd+V" } else { "Ctrl+V" }).await
1164    }
1165}
1166
1167fn parse_key_combo(combo: &str) -> (i32, &str) {
1168    use crate::cdp::types::modifiers;
1169    let parts: Vec<&str> = combo.split('+').collect();
1170    let mut mods = 0;
1171    let mut key = combo;
1172    for (i, part) in parts.iter().enumerate() {
1173        match part.to_lowercase().as_str() {
1174            "ctrl" | "control" => mods |= modifiers::CTRL,
1175            "alt" | "option" => mods |= modifiers::ALT,
1176            "shift" => mods |= modifiers::SHIFT,
1177            "cmd" | "meta" | "command" => mods |= modifiers::META,
1178            _ => key = parts[i],
1179        }
1180    }
1181    (mods, key)
1182}
1183
1184fn key_to_codes(key: &str) -> (&str, &str, Option<i32>) {
1185    static KEYS: &[(&str, &str, &str, i32)] = &[
1186        ("enter", "Enter", "Enter", 13),
1187        ("return", "Enter", "Enter", 13),
1188        ("tab", "Tab", "Tab", 9),
1189        ("escape", "Escape", "Escape", 27),
1190        ("esc", "Escape", "Escape", 27),
1191        ("backspace", "Backspace", "Backspace", 8),
1192        ("delete", "Delete", "Delete", 46),
1193        ("arrowup", "ArrowUp", "ArrowUp", 38),
1194        ("up", "ArrowUp", "ArrowUp", 38),
1195        ("arrowdown", "ArrowDown", "ArrowDown", 40),
1196        ("down", "ArrowDown", "ArrowDown", 40),
1197        ("arrowleft", "ArrowLeft", "ArrowLeft", 37),
1198        ("left", "ArrowLeft", "ArrowLeft", 37),
1199        ("arrowright", "ArrowRight", "ArrowRight", 39),
1200        ("right", "ArrowRight", "ArrowRight", 39),
1201        ("home", "Home", "Home", 36),
1202        ("end", "End", "End", 35),
1203        ("pageup", "PageUp", "PageUp", 33),
1204        ("pagedown", "PageDown", "PageDown", 34),
1205        ("space", " ", "Space", 32),
1206        ("a", "a", "KeyA", 65),
1207        ("b", "b", "KeyB", 66),
1208        ("c", "c", "KeyC", 67),
1209        ("d", "d", "KeyD", 68),
1210        ("e", "e", "KeyE", 69),
1211        ("f", "f", "KeyF", 70),
1212        ("g", "g", "KeyG", 71),
1213        ("h", "h", "KeyH", 72),
1214        ("i", "i", "KeyI", 73),
1215        ("j", "j", "KeyJ", 74),
1216        ("k", "k", "KeyK", 75),
1217        ("l", "l", "KeyL", 76),
1218        ("m", "m", "KeyM", 77),
1219        ("n", "n", "KeyN", 78),
1220        ("o", "o", "KeyO", 79),
1221        ("p", "p", "KeyP", 80),
1222        ("q", "q", "KeyQ", 81),
1223        ("r", "r", "KeyR", 82),
1224        ("s", "s", "KeyS", 83),
1225        ("t", "t", "KeyT", 84),
1226        ("u", "u", "KeyU", 85),
1227        ("v", "v", "KeyV", 86),
1228        ("w", "w", "KeyW", 87),
1229        ("x", "x", "KeyX", 88),
1230        ("y", "y", "KeyY", 89),
1231        ("z", "z", "KeyZ", 90),
1232        ("f1", "F1", "F1", 112),
1233        ("f2", "F2", "F2", 113),
1234        ("f3", "F3", "F3", 114),
1235        ("f4", "F4", "F4", 115),
1236        ("f5", "F5", "F5", 116),
1237        ("f6", "F6", "F6", 117),
1238        ("f7", "F7", "F7", 118),
1239        ("f8", "F8", "F8", 119),
1240        ("f9", "F9", "F9", 120),
1241        ("f10", "F10", "F10", 121),
1242        ("f11", "F11", "F11", 122),
1243        ("f12", "F12", "F12", 123),
1244    ];
1245    let lower = key.to_lowercase();
1246    KEYS.iter()
1247        .find(|(name, _, _, _)| *name == lower)
1248        .map(|(_, k, c, vk)| (*k, *c, Some(*vk)))
1249        .unwrap_or((key, key, None))
1250}
1251
1252/// A captured HTTP request with its response
1253#[derive(Debug, Clone)]
1254pub struct CapturedRequest {
1255    pub request_id: String,
1256    pub url: String,
1257    pub method: String,
1258    pub headers: HashMap<String, String>,
1259    pub post_data: Option<String>,
1260    pub resource_type: Option<String>,
1261    pub status: Option<i32>,
1262    pub status_text: Option<String>,
1263    pub response_headers: Option<HashMap<String, String>>,
1264    pub mime_type: Option<String>,
1265    pub timestamp: f64,
1266    pub complete: bool,
1267}
1268
1269/// Response body - either text or binary
1270#[derive(Debug)]
1271pub enum ResponseBody {
1272    Text(String),
1273    Binary(Vec<u8>),
1274}
1275
1276impl ResponseBody {
1277    /// Get as text (panics if binary)
1278    pub fn as_text(&self) -> Option<&str> {
1279        match self {
1280            ResponseBody::Text(s) => Some(s),
1281            ResponseBody::Binary(_) => None,
1282        }
1283    }
1284
1285    /// Get as bytes
1286    pub fn as_bytes(&self) -> &[u8] {
1287        match self {
1288            ResponseBody::Text(s) => s.as_bytes(),
1289            ResponseBody::Binary(b) => b,
1290        }
1291    }
1292}
1293
1294/// Information about a frame/iframe
1295#[derive(Debug, Clone)]
1296pub struct FrameInfo {
1297    /// Frame ID
1298    pub id: String,
1299    /// Frame URL
1300    pub url: String,
1301    /// Frame name (if any)
1302    pub name: Option<String>,
1303}
1304
1305/// Debug information about page state
1306#[derive(Debug, Clone, serde::Deserialize)]
1307pub struct PageState {
1308    pub url: String,
1309    pub title: String,
1310    pub input_count: u32,
1311    pub button_count: u32,
1312    pub link_count: u32,
1313    pub form_count: u32,
1314}
1315
1316/// Bounding box of an element
1317#[derive(Debug, Clone, Copy)]
1318pub struct BoundingBox {
1319    pub x: f64,
1320    pub y: f64,
1321    pub width: f64,
1322    pub height: f64,
1323}
1324
1325impl BoundingBox {
1326    pub fn center(&self) -> (f64, f64) {
1327        (self.x + self.width / 2.0, self.y + self.height / 2.0)
1328    }
1329}
1330
1331/// An element on the page (holds a CDP node_id, can become stale on DOM changes)
1332pub struct Element<'a> {
1333    page: &'a Page,
1334    node_id: i32,
1335}
1336
1337impl<'a> Element<'a> {
1338    /// Get the element's center coordinates
1339    pub async fn center(&self) -> Result<(f64, f64)> {
1340        let model = self.page.session.get_box_model(self.node_id).await?;
1341        Ok(model.center())
1342    }
1343
1344    /// Click this element
1345    pub async fn click(&self) -> Result<()> {
1346        let (x, y) = self.center().await?;
1347        self.page.click_at(x, y).await
1348    }
1349
1350    /// Human-like click
1351    pub async fn human_click(&self) -> Result<()> {
1352        let (x, y) = self.center().await?;
1353        self.page.human().move_and_click(x, y).await
1354    }
1355
1356    /// Get outer HTML
1357    pub async fn outer_html(&self) -> Result<String> {
1358        self.page.session.get_outer_html(self.node_id).await
1359    }
1360
1361    /// Get inner text
1362    ///
1363    /// Extracts text content from the element's outerHTML without using focus.
1364    pub async fn text(&self) -> Result<String> {
1365        self.eval_str("this.textContent || ''").await
1366    }
1367
1368    /// Evaluate a JavaScript expression on this element via Runtime.callFunctionOn.
1369    ///
1370    /// The expression should use `this` to refer to the element.
1371    /// Example: `"this.textContent || ''"`, `"this.tagName.toLowerCase()"`
1372    async fn eval_on_element(&self, js_body: &str) -> Result<serde_json::Value> {
1373        let object_id = self.page.session.resolve_node(self.node_id).await?;
1374        let func = format!("function() {{ return {}; }}", js_body);
1375        let result = self.page.session.call_function_on(&object_id, &func).await?;
1376        Ok(result.result.value.unwrap_or(serde_json::Value::Null))
1377    }
1378
1379    /// Evaluate JS on element, return as String (empty string on null/non-string)
1380    async fn eval_str(&self, js_body: &str) -> Result<String> {
1381        let value = self.eval_on_element(js_body).await?;
1382        Ok(value.as_str().unwrap_or("").to_string())
1383    }
1384
1385    /// Evaluate JS on element, return as bool with a default
1386    async fn eval_bool(&self, js_body: &str, default: bool) -> Result<bool> {
1387        let value = self.eval_on_element(js_body).await?;
1388        Ok(value.as_bool().unwrap_or(default))
1389    }
1390
1391    /// Type text into this element
1392    pub async fn type_text(&self, text: &str) -> Result<()> {
1393        self.click().await?;
1394        sleep_ms(INTERACTION_DELAY_MS).await;
1395        self.page.session.insert_text(text).await
1396    }
1397
1398    /// Focus this element
1399    pub async fn focus(&self) -> Result<()> {
1400        self.page.session.focus(self.node_id).await
1401    }
1402    /// Check if the element is visible (has a computable box model)
1403    pub async fn is_visible(&self) -> Result<bool> {
1404        match self.page.session.get_box_model(self.node_id).await {
1405            Ok(_) => Ok(true),
1406            Err(Error::Cdp { message, .. }) if message.contains("box model") => Ok(false),
1407            Err(e) => Err(e),
1408        }
1409    }
1410
1411    /// Get the element's bounding box
1412    ///
1413    /// Returns None if the element is not visible/rendered.
1414    pub async fn bounding_box(&self) -> Option<BoundingBox> {
1415        match self.page.session.get_box_model(self.node_id).await {
1416            Ok(model) => {
1417                let content = &model.content;
1418                if content.len() >= 8 {
1419                    // content is [x1,y1, x2,y2, x3,y3, x4,y4] for a quad
1420                    // Handle rotated/transformed elements by finding actual bounds
1421                    let xs = [content[0], content[2], content[4], content[6]];
1422                    let ys = [content[1], content[3], content[5], content[7]];
1423
1424                    let min_x = xs.iter().copied().fold(f64::INFINITY, f64::min);
1425                    let max_x = xs.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1426                    let min_y = ys.iter().copied().fold(f64::INFINITY, f64::min);
1427                    let max_y = ys.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1428
1429                    Some(BoundingBox {
1430                        x: min_x,
1431                        y: min_y,
1432                        width: max_x - min_x,
1433                        height: max_y - min_y,
1434                    })
1435                } else {
1436                    None
1437                }
1438            }
1439            Err(_) => None,
1440        }
1441    }
1442
1443    /// Get an attribute value
1444    pub async fn get_attribute(&self, name: &str) -> Result<Option<String>> {
1445        let escaped_name = escape_js_string(name);
1446        let value = self
1447            .eval_on_element(&format!("this.getAttribute('{}')", escaped_name))
1448            .await?;
1449
1450        if value.is_null() {
1451            return Ok(None);
1452        }
1453        if let Some(s) = value.as_str() {
1454            return Ok(Some(s.to_string()));
1455        }
1456        Ok(None)
1457    }
1458
1459    /// Get the tag name of the element (e.g., "div", "input", "a")
1460    pub async fn tag_name(&self) -> Result<String> {
1461        self.eval_str("this.tagName.toLowerCase()").await
1462    }
1463
1464    /// Check if the element is enabled (not disabled)
1465    pub async fn is_enabled(&self) -> Result<bool> {
1466        self.eval_bool("!this.disabled", true).await
1467    }
1468
1469    /// Check if a checkbox/radio is checked
1470    pub async fn is_checked(&self) -> Result<bool> {
1471        self.eval_bool("this.checked === true", false).await
1472    }
1473
1474    /// Get the value of an input element
1475    pub async fn value(&self) -> Result<String> {
1476        self.eval_str("this.value || ''").await
1477    }
1478
1479    /// Get computed CSS property value
1480    pub async fn css(&self, property: &str) -> Result<String> {
1481        let escaped = escape_js_string(property);
1482        self.eval_str(&format!(
1483            "getComputedStyle(this).getPropertyValue('{}')",
1484            escaped
1485        ))
1486        .await
1487    }
1488
1489    /// Scroll this element into view
1490    pub async fn scroll_into_view(&self) -> Result<()> {
1491        let object_id = self.page.session.resolve_node(self.node_id).await?;
1492        self.page
1493            .session
1494            .call_function_on(
1495                &object_id,
1496                "function() { this.scrollIntoView({ behavior: 'smooth', block: 'center' }); }",
1497            )
1498            .await?;
1499        Ok(())
1500    }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506
1507    #[test]
1508    fn test_parse_key_combo_simple() {
1509        let (mods, key) = parse_key_combo("Enter");
1510        assert_eq!(mods, 0);
1511        assert_eq!(key, "Enter");
1512    }
1513
1514    #[test]
1515    fn test_parse_key_combo_ctrl() {
1516        use crate::cdp::types::modifiers;
1517        let (mods, key) = parse_key_combo("Ctrl+A");
1518        assert_eq!(mods, modifiers::CTRL);
1519        assert_eq!(key, "A");
1520    }
1521
1522    #[test]
1523    fn test_parse_key_combo_cmd_shift() {
1524        use crate::cdp::types::modifiers;
1525        let (mods, key) = parse_key_combo("Cmd+Shift+S");
1526        assert_eq!(mods, modifiers::META | modifiers::SHIFT);
1527        assert_eq!(key, "S");
1528    }
1529
1530    #[test]
1531    fn test_parse_key_combo_all_modifiers() {
1532        use crate::cdp::types::modifiers;
1533        let (mods, key) = parse_key_combo("Ctrl+Alt+Shift+Cmd+X");
1534        assert_eq!(
1535            mods,
1536            modifiers::CTRL | modifiers::ALT | modifiers::SHIFT | modifiers::META
1537        );
1538        assert_eq!(key, "X");
1539    }
1540
1541    #[test]
1542    fn test_parse_key_combo_case_insensitive() {
1543        use crate::cdp::types::modifiers;
1544        let (mods, key) = parse_key_combo("ctrl+a");
1545        assert_eq!(mods, modifiers::CTRL);
1546        assert_eq!(key, "a");
1547    }
1548
1549    #[test]
1550    fn test_key_to_codes_enter() {
1551        let (key, code, vk) = key_to_codes("Enter");
1552        assert_eq!(key, "Enter");
1553        assert_eq!(code, "Enter");
1554        assert_eq!(vk, Some(13));
1555    }
1556
1557    #[test]
1558    fn test_key_to_codes_tab() {
1559        let (key, code, vk) = key_to_codes("Tab");
1560        assert_eq!(key, "Tab");
1561        assert_eq!(code, "Tab");
1562        assert_eq!(vk, Some(9));
1563    }
1564
1565    #[test]
1566    fn test_key_to_codes_letter() {
1567        let (key, code, vk) = key_to_codes("a");
1568        assert_eq!(key, "a");
1569        assert_eq!(code, "KeyA");
1570        assert_eq!(vk, Some(65));
1571    }
1572
1573    #[test]
1574    fn test_key_to_codes_arrow() {
1575        let (key, code, vk) = key_to_codes("ArrowDown");
1576        assert_eq!(key, "ArrowDown");
1577        assert_eq!(code, "ArrowDown");
1578        assert_eq!(vk, Some(40));
1579    }
1580
1581    #[test]
1582    fn test_key_to_codes_case_insensitive() {
1583        let (key, code, vk) = key_to_codes("ESCAPE");
1584        assert_eq!(key, "Escape");
1585        assert_eq!(code, "Escape");
1586        assert_eq!(vk, Some(27));
1587    }
1588
1589    #[test]
1590    fn test_key_to_codes_alias() {
1591        // "esc" should work as alias for "Escape"
1592        let (key, code, vk) = key_to_codes("esc");
1593        assert_eq!(key, "Escape");
1594        assert_eq!(code, "Escape");
1595        assert_eq!(vk, Some(27));
1596
1597        // "up" should work as alias for "ArrowUp"
1598        let (key, code, vk) = key_to_codes("up");
1599        assert_eq!(key, "ArrowUp");
1600        assert_eq!(code, "ArrowUp");
1601        assert_eq!(vk, Some(38));
1602    }
1603
1604    #[test]
1605    fn test_key_to_codes_unknown() {
1606        // Unknown keys should pass through
1607        let (key, code, vk) = key_to_codes("SomeWeirdKey");
1608        assert_eq!(key, "SomeWeirdKey");
1609        assert_eq!(code, "SomeWeirdKey");
1610        assert_eq!(vk, None);
1611    }
1612
1613    #[test]
1614    fn test_escape_js_string() {
1615        assert_eq!(escape_js_string("hello"), "hello");
1616        assert_eq!(escape_js_string("it's"), "it\\'s");
1617        assert_eq!(escape_js_string("line1\nline2"), "line1\\nline2");
1618        assert_eq!(escape_js_string("back\\slash"), "back\\\\slash");
1619        assert_eq!(escape_js_string("${var}"), "\\${var}");
1620        // Null byte and Unicode line terminators must be escaped
1621        assert_eq!(escape_js_string("a\0b"), "a\\0b");
1622        assert_eq!(escape_js_string("a\u{2028}b"), "a\\u2028b");
1623        assert_eq!(escape_js_string("a\u{2029}b"), "a\\u2029b");
1624    }
1625}