Skip to main content

gthings_cdp/
session.rs

1use crate::connection::{CdpEvent, Connection};
2use crate::error::{CdpError, Result};
3use crate::tab::Tab;
4use serde_json::Value;
5use std::time::Duration;
6use tokio::sync::broadcast;
7use tracing;
8
9/// High-level CDP session. Manages connection + tabs with event-driven lifecycle.
10pub struct Session {
11    conn: Connection,
12}
13
14impl std::fmt::Debug for Session {
15    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16        f.debug_struct("Session").finish_non_exhaustive()
17    }
18}
19
20/// Wait for a specific CDP event on a pre-subscribed receiver.
21/// Subscribe BEFORE the triggering action to avoid missing the event.
22async fn wait_for_event(
23    rx: &mut broadcast::Receiver<CdpEvent>,
24    method: &str,
25    predicate: impl Fn(&CdpEvent) -> bool + Send,
26    timeout: Duration,
27) -> Result<CdpEvent> {
28    tokio::time::timeout(timeout, async move {
29        loop {
30            match rx.recv().await {
31                Ok(event) if event.method.as_str() == method && predicate(&event) => {
32                    return Ok(event);
33                }
34                Ok(_) => continue,
35                Err(broadcast::error::RecvError::Closed) => {
36                    return Err(CdpError::ConnectionFailed {
37                        detail: "event channel closed while waiting".into(),
38                    });
39                }
40                Err(broadcast::error::RecvError::Lagged(n)) => {
41                    tracing::warn!("Event receiver lagged by {n} messages");
42                    continue;
43                }
44            }
45        }
46    })
47    .await
48    .map_err(|_| CdpError::NavigationTimeout {
49        url: "unknown".into(),
50        timeout: timeout.as_secs(),
51    })?
52}
53
54impl Session {
55    /// Connect to browser via WebSocket URL
56    pub async fn connect(ws_url: &str) -> Result<Self> {
57        let conn = Connection::connect(ws_url).await?;
58        Ok(Session { conn })
59    }
60
61    /// Create a new tab/page
62    pub async fn create_tab(&self, url: &str) -> Result<Tab> {
63        Tab::create(self, url).await
64    }
65
66    /// Evaluate JavaScript in a tab, return JSON result
67    pub async fn evaluate(&self, tab: &Tab, js: &str) -> Result<Value> {
68        tab.evaluate(self, js).await
69    }
70
71    /// Navigate to URL and wait for networkIdle lifecycle event
72    pub async fn navigate(&self, tab: &Tab, url: &str) -> Result<()> {
73        let conn = &self.conn;
74        let sid = tab.session_id.as_deref();
75
76        // 1. Enable Page events so we receive lifecycle events
77        conn.call("Page.enable", serde_json::json!({}), sid).await?;
78
79        // 1a. Enable lifecycle events (required for Chrome 144+)
80        conn.call(
81            "Page.setLifecycleEventsEnabled",
82            serde_json::json!({"enabled": true}),
83            sid,
84        )
85        .await?;
86
87        // 2. Subscribe BEFORE navigation — don't miss the networkIdle event
88        let mut rx = conn.event_rx();
89
90        // 3. Start navigation
91        conn.call(
92            "Page.navigate",
93            serde_json::json!({"url": url}),
94            sid,
95        )
96        .await?;
97
98        // 4. Wait for networkIdle using the pre-subscribed receiver
99        let result = wait_for_event(
100            &mut rx,
101            "Page.lifecycleEvent",
102            |evt| evt.params.get("name").and_then(|v| v.as_str()) == Some("networkIdle"),
103            Duration::from_secs(10),
104        )
105        .await;
106
107        // Fallback: if lifecycle event timed out, poll document.readyState
108        match result {
109            Ok(_) => {}
110            Err(CdpError::NavigationTimeout { .. }) => {
111                tracing::warn!("Lifecycle event timeout, falling back to readyState polling");
112                // Poll document.readyState up to 5 more seconds
113                for _ in 0..10 {
114                    if let Ok(val) = conn
115                        .call(
116                            "Runtime.evaluate",
117                            serde_json::json!({
118                                "expression": "document.readyState",
119                                "returnByValue": true
120                            }),
121                            sid,
122                        )
123                        .await
124                    {
125                        if val
126                            .get("result")
127                            .and_then(|r| r.get("value"))
128                            .and_then(|v| v.as_str())
129                            == Some("complete")
130                        {
131                            return Ok(());
132                        }
133                    }
134                    tokio::time::sleep(Duration::from_millis(500)).await;
135                }
136                return Err(CdpError::NavigationTimeout {
137                    url: url.to_string(),
138                    timeout: 15,
139                });
140            }
141            Err(e) => return Err(e),
142        }
143
144        Ok(())
145    }
146
147    /// Wait for a CDP event matching method + predicate.
148    ///
149    /// ⚠️ Creates a NEW event subscription. Call BEFORE the action that
150    /// triggers the event to avoid race conditions. For navigation, use
151    /// [`navigate()`](Session::navigate) instead.
152    pub async fn wait_for<F>(
153        &self,
154        method: &str,
155        predicate: F,
156        timeout: Duration,
157    ) -> Result<CdpEvent>
158    where
159        F: Fn(&CdpEvent) -> bool + Send + 'static,
160    {
161        let mut rx = self.conn.event_rx();
162
163        tokio::time::timeout(timeout, async move {
164            loop {
165                match rx.recv().await {
166                    Ok(event) if event.method.as_str() == method && predicate(&event) => {
167                        return Ok(event);
168                    }
169                    Ok(_) => continue,
170                    Err(broadcast::error::RecvError::Closed) => {
171                        return Err(CdpError::ConnectionFailed {
172                            detail: "event channel closed while waiting".into(),
173                        });
174                    }
175                    Err(broadcast::error::RecvError::Lagged(n)) => {
176                        tracing::warn!("Event receiver lagged by {n} messages");
177                        continue;
178                    }
179                }
180            }
181        })
182        .await
183        .map_err(|_| CdpError::CdpCallFailed {
184            method: format!("wait_for({method})"),
185            detail: format!("timeout after {timeout:?}"),
186        })?
187    }
188
189    /// Close a tab
190    pub async fn close_tab(&self, tab: Tab) -> Result<()> {
191        tab.close(self).await
192    }
193
194    /// Disconnect from browser
195    pub async fn disconnect(self) -> Result<()> {
196        self.conn.close().await;
197        Ok(())
198    }
199
200    /// Access the underlying Connection (for direct CDP calls)
201    pub fn connection(&self) -> &Connection {
202        &self.conn
203    }
204}