browsing 0.1.6

Browser automation: navigate, click, extract, screenshot. Standalone browser control via CDP.
Documentation
//! Smart waiting: poll DOM conditions instead of fixed sleeps
//!
//! Replaces `tokio::time::sleep(Duration::from_secs(3))` with
//! condition-based polling: wait for an element to appear, text to show,
//! URL to change, page intent to shift, etc.

use crate::actor::Page;
use crate::dom::views::PageIntent;
use crate::error::Result;
use std::time::{Duration, Instant};
use tracing::{debug, info};

/// What to wait for
#[derive(Debug, Clone, PartialEq)]
pub enum WaitCondition {
    /// Legacy fixed-duration wait
    FixedSeconds(u64),
    /// Wait until a CSS-selector-matched element exists in the DOM
    ElementPresent(String),
    /// Wait until an element matching the selector disappears
    ElementGone(String),
    /// Wait until the page body contains the given text
    TextPresent(String),
    /// Wait until the current URL contains the substring
    UrlContains(String),
    /// Wait until page intent changes to the specified value
    PageIntent(PageIntent),
    /// Wait until the number of elements matching selector reaches `count`
    ElementCount {
        /// CSS selector to match elements
        selector: String,
        /// Target element count
        count: usize,
    },
    /// Wait until the page is stable (no DOM mutations for `stable_ms` milliseconds)
    PageStable {
        /// Duration of stability required in milliseconds
        stable_ms: u64,
    },
}

/// Configuration for the smart waiter
#[derive(Debug, Clone)]
pub struct SmartWaitConfig {
    /// How often to poll the condition (milliseconds)
    pub poll_interval_ms: u64,
    /// Maximum total wait time (milliseconds)
    pub max_wait_ms: u64,
    /// Minimum wait time even if condition is immediately true (milliseconds)
    pub min_wait_ms: u64,
}

impl Default for SmartWaitConfig {
    fn default() -> Self {
        Self {
            poll_interval_ms: 250,
            max_wait_ms: 30_000,
            min_wait_ms: 0,
        }
    }
}

/// Result of a smart wait operation
#[derive(Debug, Clone)]
pub struct SmartWaitResult {
    /// Whether the condition was satisfied
    pub satisfied: bool,
    /// How long we actually waited
    pub elapsed_ms: u64,
    /// Which condition was being waited on
    pub condition: WaitCondition,
    /// Human-readable description of what happened
    pub description: String,
}

/// Polls the live page until a condition is met or a timeout fires.
pub struct SmartWaiter {
    config: SmartWaitConfig,
}

impl SmartWaiter {
    /// Create a waiter with default config
    pub fn new() -> Self {
        Self {
            config: SmartWaitConfig::default(),
        }
    }

    /// Create a waiter with custom config
    pub fn with_config(config: SmartWaitConfig) -> Self {
        Self { config }
    }

    /// Wait for the given condition on the supplied page.
    ///
    /// Returns `Ok(SmartWaitResult)` on success (condition met or timeout),
    /// `Err` only if a CDP / JS evaluation fails unexpectedly.
    pub async fn wait(&self, condition: &WaitCondition, page: &Page) -> Result<SmartWaitResult> {
        let start = Instant::now();
        let max_duration = Duration::from_millis(self.config.max_wait_ms);
        let poll_interval = Duration::from_millis(self.config.poll_interval_ms);

        match condition {
            WaitCondition::FixedSeconds(secs) => {
                let secs = (*secs).min(30);
                tokio::time::sleep(Duration::from_secs(secs)).await;
                Ok(SmartWaitResult {
                    satisfied: true,
                    elapsed_ms: secs * 1000,
                    condition: condition.clone(),
                    description: format!("Waited fixed {secs}s"),
                })
            }
            _ => {
                // Polling strategies
                loop {
                    let elapsed = start.elapsed();
                    if elapsed > max_duration {
                        let elapsed_ms = elapsed.as_millis() as u64;
                        info!(
                            "⏱️ Smart wait timed out after {}ms for {:?}",
                            elapsed_ms, condition
                        );
                        return Ok(SmartWaitResult {
                            satisfied: false,
                            elapsed_ms,
                            condition: condition.clone(),
                            description: format!(
                                "Timed out after {}ms waiting for {:?}",
                                elapsed_ms, condition
                            ),
                        });
                    }

                    match Self::_check_condition(condition, page).await {
                        Ok(true) => {
                            let elapsed_ms = elapsed.as_millis() as u64;
                            // Enforce minimum wait
                            if elapsed_ms < self.config.min_wait_ms {
                                tokio::time::sleep(Duration::from_millis(
                                    self.config.min_wait_ms - elapsed_ms,
                                ))
                                .await;
                            }
                            info!(
                                "⏱️ Smart wait satisfied after {}ms for {:?}",
                                elapsed_ms, condition
                            );
                            return Ok(SmartWaitResult {
                                satisfied: true,
                                elapsed_ms,
                                condition: condition.clone(),
                                description: format!(
                                    "Condition satisfied after {}ms: {:?}",
                                    elapsed_ms, condition
                                ),
                            });
                        }
                        Ok(false) => {
                            // Condition not yet met, sleep and retry
                        }
                        Err(e) => {
                            debug!("Smart wait check error: {e}");
                        }
                    }

                    // Adaptive polling: start fast, slow down after 2s
                    let adaptive_interval = if elapsed < Duration::from_secs(2) {
                        poll_interval
                    } else {
                        poll_interval * 2
                    };

                    // But don't sleep more than remaining time
                    let remaining = max_duration.saturating_sub(start.elapsed());
                    let sleep_for = adaptive_interval.min(remaining);
                    if sleep_for > Duration::ZERO {
                        tokio::time::sleep(sleep_for).await;
                    }

                    // Avoid tight loop if sleep was zero
                    if sleep_for == Duration::ZERO {
                        tokio::time::sleep(Duration::from_millis(10)).await;
                    }
                }
            }
        }
    }

    /// Check a condition once via JavaScript evaluation.
    async fn _check_condition(condition: &WaitCondition, page: &Page) -> Result<bool> {
        match condition {
            WaitCondition::ElementPresent(selector) => {
                let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'");
                let js = format!(
                    "document.querySelector('{}') !== null",
                    escaped
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::ElementGone(selector) => {
                let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'");
                let js = format!(
                    "document.querySelector('{}') === null",
                    escaped
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::TextPresent(text) => {
                let escaped = text.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'");
                let js = format!(
                    "document.body.innerText.includes('{}')",
                    escaped
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::UrlContains(substring) => {
                let escaped = substring.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'");
                let js = format!(
                    "window.location.href.includes('{}')",
                    escaped
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::PageIntent(expected) => {
                // We can't directly get the PageIntent from JS.
                // Instead, we re-serialize the DOM and check the intent.
                // For now, we check URL-based heuristics.
                let intent_desc = expected.description();
                let js = format!(
                    "(() => {{
                        const url = window.location.href.toLowerCase();
                        const title = document.title.toLowerCase();
                        const body = document.body.innerText.toLowerCase();
                        let score = 0;
                        if ('{intent_desc}'.includes('login') && (url.includes('login') || url.includes('signin') || body.includes('password') || body.includes('sign in'))) score += 1;
                        if ('{intent_desc}'.includes('search') && (url.includes('search') || title.includes('search') || body.includes('results'))) score += 1;
                        if ('{intent_desc}'.includes('product') && (url.includes('product') || body.includes('add to cart') || body.includes('price'))) score += 1;
                        if ('{intent_desc}'.includes('checkout') && (url.includes('checkout') || url.includes('cart') || body.includes('payment'))) score += 1;
                        return score > 0 ? 'true' : 'false';
                    }})()",
                    intent_desc = intent_desc.replace("'", "\\'")
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::ElementCount { selector, count } => {
                let escaped = selector.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "\\'");
                let js = format!(
                    "document.querySelectorAll('{}').length >= {}",
                    escaped, count
                );
                let result = page.evaluate(&js).await?;
                Ok(result.trim() == "true")
            }
            WaitCondition::PageStable { stable_ms } => {
                // Snapshot the DOM and compare after stable_ms
                let js1 = "document.body.innerHTML.length";
                let len1 = page.evaluate(js1).await?;
                tokio::time::sleep(Duration::from_millis(*stable_ms)).await;
                let len2 = page.evaluate(js1).await?;
                Ok(len1 == len2)
            }
            WaitCondition::FixedSeconds(_) => {
                // Should never reach here; handled in wait()
                Ok(true)
            }
        }
    }

    /// Convenience: wait after an action for the page to stabilize.
    /// Polls DOM size until it stops changing for `stable_ms`.
    pub async fn wait_for_stable(&self, page: &Page, stable_ms: u64) -> Result<SmartWaitResult> {
        self.wait(&WaitCondition::PageStable { stable_ms }, page).await
    }

    /// Convenience: wait for an element to appear.
    pub async fn wait_for_element(&self, page: &Page, selector: &str) -> Result<SmartWaitResult> {
        self.wait(&WaitCondition::ElementPresent(selector.to_string()), page)
            .await
    }

    /// Convenience: wait for text to appear.
    pub async fn wait_for_text(&self, page: &Page, text: &str) -> Result<SmartWaitResult> {
        self.wait(&WaitCondition::TextPresent(text.to_string()), page)
            .await
    }
}

impl Default for SmartWaiter {
    fn default() -> Self {
        Self::new()
    }
}