Skip to main content

gthings_cdp/
tab.rs

1use crate::connection::Connection;
2use crate::error::{CdpError, Result};
3use serde_json::{Value, json};
4use std::time::Duration;
5use tracing;
6
7
8/// A CDP tab session, representing one browser tab.
9pub struct Tab {
10    /// The CDP session ID for this tab's target
11    pub session_id: String,
12    /// The target ID
13    pub target_id: String,
14}
15
16impl Tab {
17    /// Create a new tab. Uses `Target.createTarget`; attaches via CDP if sessionId is missing.
18    pub async fn create(conn: &mut Connection, _ws_url: &str, url: &str) -> Result<Self> {
19        let result = conn
20            .call(
21                "Target.createTarget",
22                json!({
23                    "url": url,
24                    "newWindow": false,
25                }),
26            )
27            .await;
28
29        if let Ok(result) = result {
30            if let Some(session_id) = result.get("sessionId").and_then(|v| v.as_str()) {
31                let target_id = result
32                    .get("targetId")
33                    .and_then(|v| v.as_str())
34                    .ok_or_else(|| CdpError::CommandFailed {
35                        method: "Target.createTarget".into(),
36                        msg: "no targetId in response".into(),
37                    })?
38                    .to_string();
39
40                tracing::debug!("Created tab: target={}, session={}", target_id, session_id);
41
42                return Ok(Tab {
43                    session_id: session_id.to_string(),
44                    target_id,
45                });
46            }
47            // Missing sessionId — Dia returns targetId without sessionId.
48            // Attach to the target via CDP instead of falling back to HTTP
49            // (Dia doesn't support HTTP endpoints like /json/new).
50            if let Some(target_id) = result.get("targetId").and_then(|v| v.as_str()) {
51                tracing::warn!(
52                    "Target.createTarget returned targetId without sessionId, attaching..."
53                );
54                let attach = conn
55                    .call(
56                        "Target.attachToTarget",
57                        json!({
58                            "targetId": target_id,
59                            "flatten": true,
60                        }),
61                    )
62                    .await
63                    .map_err(|e| CdpError::CommandFailed {
64                        method: "Target.attachToTarget".into(),
65                        msg: format!("attach failed: {e}"),
66                    })?;
67
68                let session_id = attach
69                    .get("sessionId")
70                    .and_then(|v| v.as_str())
71                    .ok_or_else(|| CdpError::CommandFailed {
72                        method: "Target.attachToTarget".into(),
73                        msg: "no sessionId in attach response".into(),
74                    })?
75                    .to_string();
76
77                tracing::info!(
78                    "Attached to created target: {} (session={})",
79                    target_id,
80                    session_id
81                );
82
83                return Ok(Tab {
84                    session_id,
85                    target_id: target_id.to_string(),
86                });
87            }
88            tracing::warn!(
89                "Target.createTarget returned neither sessionId nor targetId, cannot attach"
90            );
91        } else {
92            tracing::warn!("Target.createTarget failed");
93        }
94
95        Err(CdpError::CommandFailed {
96            method: "Target.createTarget".into(),
97            msg: "could not create tab: all methods exhausted".into(),
98        })
99    }
100
101    /// Navigate the tab to a URL. Waits for the page to reach a stable state.
102    pub async fn navigate(&self, conn: &mut Connection, url: &str) -> Result<()> {
103        tracing::info!("Navigating to: {}", url);
104
105        let result = conn
106            .call_with_session(&self.session_id, "Page.enable", json!({}))
107            .await?;
108        tracing::debug!("Page.enable: {:?}", result);
109
110        let _result = conn
111            .call_with_session(
112                &self.session_id,
113                "Page.navigate",
114                json!({
115                    "url": url,
116                }),
117            )
118            .await?;
119
120        // Wait for page readyState
121        self.wait_for_page_load(conn, 10000).await
122    }
123
124    /// Wait for the page to reach `complete` readyState, with partial content fallback.
125    async fn wait_for_page_load(&self, conn: &mut Connection, timeout_ms: u64) -> Result<()> {
126        let start = std::time::Instant::now();
127        let timeout = Duration::from_millis(timeout_ms);
128
129        loop {
130            if start.elapsed() > timeout {
131                tracing::warn!("Page load timed out after {}ms", timeout_ms);
132                return Ok(()); // Don't fail on timeout — content may still be partial
133            }
134
135            let result = conn
136                .call_with_session(
137                    &self.session_id,
138                    "Runtime.evaluate",
139                    json!({
140                        "expression": "document.readyState",
141                        "returnByValue": true,
142                    }),
143                )
144                .await?;
145
146            let ready_state = result["result"]["value"].as_str().unwrap_or("unknown");
147
148            if ready_state == "complete" {
149                tracing::debug!("Page loaded (readyState=complete)");
150                return Ok(());
151            }
152
153            // Fallback: check for partial content
154            let has_content = conn.call_with_session(&self.session_id, "Runtime.evaluate", json!({
155                "expression": "document.body ? document.body.innerText.length > 100 : false",
156                "returnByValue": true,
157            })).await?;
158
159            if has_content["result"]["value"].as_bool().unwrap_or(false) {
160                tracing::debug!("Page has sufficient content (readyState={})", ready_state);
161                return Ok(());
162            }
163
164            tokio::time::sleep(Duration::from_millis(200)).await;
165        }
166    }
167
168    /// Evaluate JavaScript in the tab context and return the result.
169    pub async fn evaluate(&self, conn: &mut Connection, js: &str) -> Result<Value> {
170        let result = conn
171            .call_with_session(
172                &self.session_id,
173                "Runtime.evaluate",
174                json!({
175                    "expression": js,
176                    "returnByValue": true,
177                }),
178            )
179            .await?;
180
181        Ok(result)
182    }
183
184    /// Extract the page content. Returns innerText of body.
185    pub async fn extract_text(&self, conn: &mut Connection) -> Result<String> {
186        let result = self
187            .evaluate(conn, "document.body?.innerText || ''")
188            .await?;
189        let text = result["result"]["value"].as_str().unwrap_or("").to_string();
190        Ok(text)
191    }
192
193    /// Extract the page title.
194    pub async fn extract_title(&self, conn: &mut Connection) -> Result<String> {
195        let result = self.evaluate(conn, "document.title || ''").await?;
196        let title = result["result"]["value"].as_str().unwrap_or("").to_string();
197        Ok(title)
198    }
199
200    /// Extract HTML content.
201    pub async fn extract_html(&self, conn: &mut Connection) -> Result<String> {
202        let result = self
203            .evaluate(conn, "document.documentElement?.outerHTML || ''")
204            .await?;
205        let html = result["result"]["value"].as_str().unwrap_or("").to_string();
206        Ok(html)
207    }
208
209    /// Execute multiple JS snippets in sequence.
210    pub async fn evaluate_all(
211        &self,
212        conn: &mut Connection,
213        scripts: &[&str],
214    ) -> Result<Vec<Value>> {
215        let mut results = Vec::with_capacity(scripts.len());
216        for script in scripts {
217            let result = self.evaluate(conn, script).await?;
218            results.push(result);
219        }
220        Ok(results)
221    }
222
223    /// Close this tab. Uses `window.close()` first, then `Target.closeTarget` as fallback.
224    /// Errors are logged but not propagated.
225    pub async fn close(self, conn: &mut Connection) {
226        let _ = conn
227            .call_with_session(
228                &self.session_id,
229                "Runtime.evaluate",
230                json!({
231                    "expression": "window.close()",
232                }),
233            )
234            .await;
235
236        // Short delay for clean shutdown
237        tokio::time::sleep(Duration::from_millis(200)).await;
238
239        let _ = conn
240            .call(
241                "Target.closeTarget",
242                json!({
243                    "targetId": self.target_id,
244                }),
245            )
246            .await;
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn test_tab_struct_creation() {
256        let tab = Tab {
257            session_id: "test-session".into(),
258            target_id: "test-target".into(),
259        };
260        assert_eq!(tab.session_id, "test-session");
261        assert_eq!(tab.target_id, "test-target");
262    }
263}