chromewright 0.4.0

Browser automation MCP server via Chrome DevTools Protocol (CDP)
Documentation
use super::{BrowserSession, ClosedTabSummary, ManagedTabsCloseSummary, TabInfo};
use crate::browser::backend::TabDescriptor;
#[cfg(test)]
use crate::error::PageTargetLostDetails;
use crate::error::{BrowserError, Result};

impl BrowserSession {
    pub(crate) fn tab_overview(&self) -> Result<Vec<TabInfo>> {
        let tabs = self.backend.list_tabs()?;
        let active_id = match self.backend.active_tab() {
            Ok(tab) => Some(tab.id),
            Err(BrowserError::TabOperationFailed(reason))
                if reason.contains("No active tab found") =>
            {
                None
            }
            Err(BrowserError::PageTargetLost(details)) if details.is_attach_session_degraded() => {
                None
            }
            Err(err) => return Err(err),
        };

        Ok(tabs
            .into_iter()
            .map(|tab| TabInfo {
                active: active_id.as_deref() == Some(tab.id.as_str()),
                id: tab.id,
                title: tab.title,
                url: tab.url,
            })
            .collect())
    }

    pub(crate) fn activate_tab_by_id(&self, tab_id: &str) -> Result<()> {
        self.backend.activate_tab(tab_id)?;
        self.invalidate_snapshot_cache()
    }

    pub(crate) fn open_tab_entry(&self, url: &str) -> Result<TabDescriptor> {
        let tab = self.backend.open_tab(url)?;
        self.remember_managed_tab(tab.id.clone())?;
        self.invalidate_snapshot_cache()?;
        Ok(tab)
    }

    pub(crate) fn close_active_tab_summary(&self) -> Result<ClosedTabSummary> {
        let tabs = self.backend.list_tabs()?;
        let active = self.backend.active_tab()?;
        let index = tabs.iter().position(|tab| tab.id == active.id).unwrap_or(0);

        self.backend.close_tab(&active.id, true)?;
        self.forget_managed_tab(&active.id)?;
        self.invalidate_snapshot_cache()?;
        let active_tab = self.tab_overview()?.into_iter().find(|tab| tab.active);

        Ok(ClosedTabSummary {
            index,
            id: active.id,
            title: active.title,
            url: active.url,
            active_tab,
        })
    }

    pub(crate) fn close_managed_tabs(&self) -> Result<ManagedTabsCloseSummary> {
        let tabs = self.tab_overview()?;
        let mut managed_tabs = Vec::new();

        for tab in &tabs {
            if self.is_tab_managed(&tab.id)? {
                managed_tabs.push(tab.clone());
            }
        }

        let skipped_tabs = tabs.len().saturating_sub(managed_tabs.len());
        let attempted = managed_tabs.len();
        let mut closed_tabs = 0usize;
        let mut failures = Vec::new();

        for tab in managed_tabs {
            match self.backend.close_tab(&tab.id, false) {
                Ok(()) => {
                    self.forget_managed_tab(&tab.id)?;
                    closed_tabs += 1;
                }
                Err(err) => failures.push(format!(
                    "failed to close '{}' ({}) [id={}]: {}",
                    tab.title, tab.url, tab.id, err
                )),
            }
        }

        if failures.is_empty() {
            if closed_tabs > 0 {
                self.invalidate_snapshot_cache()?;
            }
            Ok(ManagedTabsCloseSummary {
                closed_tabs,
                skipped_tabs,
            })
        } else {
            Err(BrowserError::TabOperationFailed(format!(
                "Managed session close encountered {} error(s) after attempting {} managed tab(s): {}",
                failures.len(),
                attempted,
                failures.join("; ")
            )))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::browser::backend::{FakeSessionBackend, ScriptEvaluation, SessionBackend};
    use crate::browser::session::{
        BrowserSession, SessionOrigin, SnapshotCacheEntry, SnapshotCacheScope,
    };
    use crate::dom::{DocumentMetadata, DomTree, SnapshotNode};
    use std::sync::Arc;
    use std::time::Duration;

    struct DegradedActiveTabBackend;

    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::<[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");
    }

    impl SessionBackend for DegradedActiveTabBackend {
        fn navigate(&self, _url: &str) -> Result<()> {
            unreachable!("navigate is not used in this test")
        }

        fn wait_for_navigation(&self) -> Result<()> {
            unreachable!("wait_for_navigation is not used in this test")
        }

        fn wait_for_document_ready_with_timeout(&self, _timeout: Duration) -> Result<()> {
            unreachable!("wait_for_document_ready_with_timeout is not used in this test")
        }

        fn document_metadata(&self) -> Result<DocumentMetadata> {
            unreachable!("document_metadata is not used in this test")
        }

        fn extract_dom(&self) -> Result<DomTree> {
            unreachable!("extract_dom is not used in this test")
        }

        fn extract_dom_with_prefix(&self, _prefix: &str) -> Result<DomTree> {
            unreachable!("extract_dom_with_prefix is not used in this test")
        }

        fn evaluate(&self, _script: &str, _await_promise: bool) -> Result<ScriptEvaluation> {
            unreachable!("evaluate is not used in this test")
        }

        fn capture_screenshot(&self, _full_page: bool) -> Result<Vec<u8>> {
            unreachable!("capture_screenshot is not used in this test")
        }

        fn press_key(&self, _key: &str) -> Result<()> {
            unreachable!("press_key is not used in this test")
        }

        fn list_tabs(&self) -> Result<Vec<TabDescriptor>> {
            Ok(vec![TabDescriptor {
                id: "tab-1".to_string(),
                title: "Existing".to_string(),
                url: "https://example.com".to_string(),
            }])
        }

        fn active_tab(&self) -> Result<TabDescriptor> {
            Err(BrowserError::PageTargetLost(
                PageTargetLostDetails::attach_degraded(
                    "active_tab",
                    "DOM-backed page access is degraded".to_string(),
                    "Run tab_list, then switch_tab to reacquire an active page target.",
                ),
            ))
        }

        fn open_tab(&self, _url: &str) -> Result<TabDescriptor> {
            unreachable!("open_tab is not used in this test")
        }

        fn activate_tab(&self, _tab_id: &str) -> Result<()> {
            unreachable!("activate_tab is not used in this test")
        }

        fn close_tab(&self, _tab_id: &str, _with_unload: bool) -> Result<()> {
            unreachable!("close_tab is not used in this test")
        }

        fn close(&self) -> Result<()> {
            unreachable!("close is not used in this test")
        }
    }

    #[test]
    fn tab_overview_does_not_claim_an_active_tab_when_attach_session_is_degraded() {
        let session = BrowserSession::with_test_backend_origin(
            DegradedActiveTabBackend,
            SessionOrigin::Connected,
        );

        let tabs = session
            .tab_overview()
            .expect("tab inventory should still be available");

        assert_eq!(tabs.len(), 1);
        assert_eq!(tabs[0].id, "tab-1");
        assert!(!tabs[0].active);
    }

    #[test]
    fn activate_tab_invalidates_snapshot_cache() {
        let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
        let opened = session
            .open_tab_entry("https://second.example")
            .expect("second tab should open");

        seed_snapshot_cache(&session);
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_some()
        );

        session
            .activate_tab_by_id("tab-1")
            .expect("tab activation should succeed");
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );
        assert_eq!(opened.id, "tab-2");
    }

    #[test]
    fn open_and_close_tab_seams_invalidate_snapshot_cache() {
        let session = BrowserSession::with_test_backend(FakeSessionBackend::new());

        seed_snapshot_cache(&session);
        session
            .open_tab_entry("https://second.example")
            .expect("tab open should succeed");
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );

        seed_snapshot_cache(&session);
        session
            .close_active_tab_summary()
            .expect("active tab close should succeed");
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );
    }

    #[test]
    fn close_managed_tabs_invalidates_snapshot_cache_when_tabs_are_closed() {
        let session = BrowserSession::with_test_backend(FakeSessionBackend::new());
        session
            .open_tab_entry("https://managed.example")
            .expect("managed tab should open");

        seed_snapshot_cache(&session);
        let summary = session
            .close_managed_tabs()
            .expect("managed tab close should succeed");

        assert_eq!(summary.closed_tabs, 2);
        assert!(
            session
                .snapshot_cache_for_test()
                .expect("snapshot cache should be readable")
                .is_none()
        );
    }
}