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;
6use url::Url;
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`; falls back to HTTP `/json/new` for Dia.
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, fall back to HTTP attach
48            tracing::warn!(
49                "Target.createTarget returned targetId without sessionId, falling back to HTTP"
50            );
51        } else {
52            tracing::warn!("Target.createTarget failed, trying HTTP /json/new");
53        }
54
55        // Fallback: HTTP /json/new for Dia compatibility
56        Self::create_via_http(conn, ws_url, url).await
57    }
58
59    /// Create a target via the HTTP `/json/new` endpoint.
60    async fn create_via_http(conn: &mut Connection, ws_url: &str, url: &str) -> Result<Self> {
61        // Parse port from ws_url
62        let parsed = Url::parse(ws_url).map_err(|_| CdpError::CommandFailed {
63            method: "create_via_http".into(),
64            msg: "invalid ws_url".into(),
65        })?;
66        let port = parsed.port().ok_or_else(|| CdpError::CommandFailed {
67            method: "create_via_http".into(),
68            msg: "no port in ws_url".into(),
69        })?;
70        let host = parsed.host_str().unwrap_or("127.0.0.1");
71
72        let http_url = format!("http://{}:{}/json/new?{}", host, port, url);
73
74        tracing::debug!("HTTP create target: {}", http_url);
75
76        let client = reqwest::Client::new();
77        let resp = client
78            .put(&http_url)
79            .send()
80            .await
81            .map_err(|e| CdpError::CommandFailed {
82                method: "create_via_http".into(),
83                msg: format!("HTTP request failed: {e}"),
84            })?;
85
86        let target_info: Value = resp.json().await.map_err(|e| CdpError::CommandFailed {
87            method: "create_via_http".into(),
88            msg: format!("HTTP response parse failed: {e}"),
89        })?;
90
91        let target_id = target_info
92            .get("id")
93            .and_then(|v| v.as_str())
94            .ok_or_else(|| CdpError::CommandFailed {
95                method: "create_via_http".into(),
96                msg: "no id in HTTP response".into(),
97            })?
98            .to_string();
99
100        tracing::debug!("Created target via HTTP: target={}", target_id);
101
102        let attach_result = conn
103            .call(
104                "Target.attachToTarget",
105                json!({
106                    "targetId": target_id,
107                    "flatten": true,
108                }),
109            )
110            .await?;
111
112        let session_id = attach_result
113            .get("sessionId")
114            .and_then(|v| v.as_str())
115            .ok_or_else(|| CdpError::CommandFailed {
116                method: "Target.attachToTarget".into(),
117                msg: "no sessionId in response".into(),
118            })?
119            .to_string();
120
121        tracing::info!(
122            "Attached to created target: {} (session={})",
123            target_id,
124            session_id
125        );
126
127        Ok(Tab {
128            session_id,
129            target_id,
130        })
131    }
132
133    /// Navigate the tab to a URL. Waits for the page to reach a stable state.
134    pub async fn navigate(&self, conn: &mut Connection, url: &str) -> Result<()> {
135        tracing::info!("Navigating to: {}", url);
136
137        let result = conn
138            .call_with_session(&self.session_id, "Page.enable", json!({}))
139            .await?;
140        tracing::debug!("Page.enable: {:?}", result);
141
142        let _result = conn
143            .call_with_session(
144                &self.session_id,
145                "Page.navigate",
146                json!({
147                    "url": url,
148                }),
149            )
150            .await?;
151
152        // Wait for page readyState
153        self.wait_for_page_load(conn, 10000).await
154    }
155
156    /// Wait for the page to reach `complete` readyState, with partial content fallback.
157    async fn wait_for_page_load(&self, conn: &mut Connection, timeout_ms: u64) -> Result<()> {
158        let start = std::time::Instant::now();
159        let timeout = Duration::from_millis(timeout_ms);
160
161        loop {
162            if start.elapsed() > timeout {
163                tracing::warn!("Page load timed out after {}ms", timeout_ms);
164                return Ok(()); // Don't fail on timeout — content may still be partial
165            }
166
167            let result = conn
168                .call_with_session(
169                    &self.session_id,
170                    "Runtime.evaluate",
171                    json!({
172                        "expression": "document.readyState",
173                        "returnByValue": true,
174                    }),
175                )
176                .await?;
177
178            let ready_state = result["result"]["value"].as_str().unwrap_or("unknown");
179
180            if ready_state == "complete" {
181                tracing::debug!("Page loaded (readyState=complete)");
182                return Ok(());
183            }
184
185            // Fallback: check for partial content
186            let has_content = conn.call_with_session(&self.session_id, "Runtime.evaluate", json!({
187                "expression": "document.body ? document.body.innerText.length > 100 : false",
188                "returnByValue": true,
189            })).await?;
190
191            if has_content["result"]["value"].as_bool().unwrap_or(false) {
192                tracing::debug!("Page has sufficient content (readyState={})", ready_state);
193                return Ok(());
194            }
195
196            tokio::time::sleep(Duration::from_millis(200)).await;
197        }
198    }
199
200    /// Evaluate JavaScript in the tab context and return the result.
201    pub async fn evaluate(&self, conn: &mut Connection, js: &str) -> Result<Value> {
202        let result = conn
203            .call_with_session(
204                &self.session_id,
205                "Runtime.evaluate",
206                json!({
207                    "expression": js,
208                    "returnByValue": true,
209                }),
210            )
211            .await?;
212
213        Ok(result)
214    }
215
216    /// Extract the page content. Returns innerText of body.
217    pub async fn extract_text(&self, conn: &mut Connection) -> Result<String> {
218        let result = self
219            .evaluate(conn, "document.body?.innerText || ''")
220            .await?;
221        let text = result["result"]["value"].as_str().unwrap_or("").to_string();
222        Ok(text)
223    }
224
225    /// Extract the page title.
226    pub async fn extract_title(&self, conn: &mut Connection) -> Result<String> {
227        let result = self.evaluate(conn, "document.title || ''").await?;
228        let title = result["result"]["value"].as_str().unwrap_or("").to_string();
229        Ok(title)
230    }
231
232    /// Extract HTML content.
233    pub async fn extract_html(&self, conn: &mut Connection) -> Result<String> {
234        let result = self
235            .evaluate(conn, "document.documentElement?.outerHTML || ''")
236            .await?;
237        let html = result["result"]["value"].as_str().unwrap_or("").to_string();
238        Ok(html)
239    }
240
241    /// Execute multiple JS snippets in sequence.
242    pub async fn evaluate_all(
243        &self,
244        conn: &mut Connection,
245        scripts: &[&str],
246    ) -> Result<Vec<Value>> {
247        let mut results = Vec::with_capacity(scripts.len());
248        for script in scripts {
249            let result = self.evaluate(conn, script).await?;
250            results.push(result);
251        }
252        Ok(results)
253    }
254
255    /// Close this tab. Uses `window.close()` first, then `Target.closeTarget` as fallback.
256    /// Errors are logged but not propagated.
257    pub async fn close(self, conn: &mut Connection) {
258        let _ = conn
259            .call_with_session(
260                &self.session_id,
261                "Runtime.evaluate",
262                json!({
263                    "expression": "window.close()",
264                }),
265            )
266            .await;
267
268        // Short delay for clean shutdown
269        tokio::time::sleep(Duration::from_millis(200)).await;
270
271        let _ = conn
272            .call(
273                "Target.closeTarget",
274                json!({
275                    "targetId": self.target_id,
276                }),
277            )
278            .await;
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_tab_struct_creation() {
288        let tab = Tab {
289            session_id: "test-session".into(),
290            target_id: "test-target".into(),
291        };
292        assert_eq!(tab.session_id, "test-session");
293        assert_eq!(tab.target_id, "test-target");
294    }
295}