chromewright 0.4.0

Browser automation MCP server via Chrome DevTools Protocol (CDP)
Documentation
use super::BrowserSession;
use crate::error::{BrowserError, Result};
use std::time::{Duration, Instant};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct HistoryNavigationMetrics {
    pub browser_evaluations: u64,
    pub poll_iterations: u64,
}

impl BrowserSession {
    fn wait_for_history_settle(
        &self,
        previous_url: &str,
        timeout: Duration,
    ) -> Result<HistoryNavigationMetrics> {
        let start = Instant::now();
        let mut observed_navigation = false;
        let mut metrics = HistoryNavigationMetrics::default();

        loop {
            metrics.poll_iterations += 1;
            let document = self.document_metadata()?;
            let current_url = document.url;
            if current_url != previous_url {
                observed_navigation = true;
            }

            metrics.browser_evaluations += 1;
            let elapsed = start.elapsed();
            let grace_period = Duration::from_millis(500);

            if document.ready_state == "complete"
                && (observed_navigation || elapsed >= grace_period)
            {
                return Ok(metrics);
            }

            if elapsed >= timeout {
                return Err(BrowserError::Timeout(format!(
                    "History navigation did not settle within {} ms",
                    timeout.as_millis()
                )));
            }

            std::thread::sleep(Duration::from_millis(50));
        }
    }

    pub(crate) fn go_back_with_metrics(&self) -> Result<HistoryNavigationMetrics> {
        let previous_url = self.document_metadata()?.url;
        let go_back_js = r#"
            (function() {
                window.history.back();
                return true;
            })()
        "#;

        self.evaluate(go_back_js, false)
            .map_err(|e| BrowserError::NavigationFailed(format!("Failed to go back: {}", e)))?;
        self.invalidate_snapshot_cache()?;
        let settle_metrics = self.wait_for_history_settle(&previous_url, Duration::from_secs(5))?;

        Ok(HistoryNavigationMetrics {
            browser_evaluations: settle_metrics.browser_evaluations + 1,
            poll_iterations: settle_metrics.poll_iterations,
        })
    }

    pub(crate) fn go_forward_with_metrics(&self) -> Result<HistoryNavigationMetrics> {
        let previous_url = self.document_metadata()?.url;
        let go_forward_js = r#"
            (function() {
                window.history.forward();
                return true;
            })()
        "#;

        self.evaluate(go_forward_js, false)
            .map_err(|e| BrowserError::NavigationFailed(format!("Failed to go forward: {}", e)))?;
        self.invalidate_snapshot_cache()?;
        let settle_metrics = self.wait_for_history_settle(&previous_url, Duration::from_secs(5))?;

        Ok(HistoryNavigationMetrics {
            browser_evaluations: settle_metrics.browser_evaluations + 1,
            poll_iterations: settle_metrics.poll_iterations,
        })
    }
}

#[cfg(test)]
mod tests {
    use crate::browser::backend::FakeSessionBackend;
    use crate::browser::session::{BrowserSession, SnapshotCacheEntry, SnapshotCacheScope};
    use std::sync::Arc;

    fn seed_snapshot_cache(session: &BrowserSession) {
        let document = session
            .document_metadata()
            .expect("document metadata should be available");

        session
            .store_snapshot_cache(Arc::new(SnapshotCacheEntry {
                document,
                snapshot: Arc::<str>::from("button \"Fake target\""),
                nodes: Arc::<[crate::dom::SnapshotNode]>::from(Vec::new()),
                scope: SnapshotCacheScope {
                    mode: "viewport".to_string(),
                    fallback_mode: None,
                    viewport_biased: true,
                    returned_node_count: 0,
                    unavailable_frame_count: 0,
                    global_interactive_count: Some(1),
                },
            }))
            .expect("snapshot cache should store");
    }

    #[test]
    fn history_navigation_invalidates_snapshot_cache() {
        let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
        seed_snapshot_cache(&session);
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_some()
        );

        session
            .go_back_with_metrics()
            .expect("history navigation should succeed");
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );

        let mut current = session
            .document_metadata()
            .expect("document metadata should still be readable");
        current.revision.push_str("-forward");

        session
            .store_snapshot_cache(Arc::new(SnapshotCacheEntry {
                document: current,
                snapshot: Arc::<str>::from("button \"Fake target\""),
                nodes: Arc::<[crate::dom::SnapshotNode]>::from(Vec::new()),
                scope: SnapshotCacheScope {
                    mode: "viewport".to_string(),
                    fallback_mode: None,
                    viewport_biased: true,
                    returned_node_count: 0,
                    unavailable_frame_count: 0,
                    global_interactive_count: Some(1),
                },
            }))
            .expect("snapshot cache should store");
        session
            .go_forward_with_metrics()
            .expect("history forward should succeed");
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );
    }
}