Skip to main content

browser_control/session/
targets.rs

1//! Unified page-target listing across CDP and BiDi.
2
3use anyhow::{anyhow, Result};
4use regex::Regex;
5use serde::Serialize;
6use serde_json::{json, Value};
7
8use crate::bidi::BidiClient;
9use crate::cdp::CdpClient;
10use crate::detect::Engine;
11
12/// Normalised view of a page-like target across engines.
13///
14/// For CDP this maps to a `targetInfo` entry of `type == "page"`. For BiDi
15/// this maps to a top-level browsing context.
16#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct TargetInfo {
18    /// Engine-specific id (CDP `targetId` or BiDi `context`).
19    pub id: String,
20    /// Page URL, possibly empty for new tabs.
21    pub url: String,
22    /// Page title, possibly empty.
23    pub title: String,
24    /// Always `"page"` for CDP; `"context"` for BiDi.
25    pub kind: String,
26}
27
28/// Connect to `endpoint` (per `engine`) and return the list of page targets,
29/// filtered by `url_regex` if given. The regex is unanchored; use `^…$` if
30/// strict matching is desired.
31pub async fn list(
32    endpoint: &str,
33    engine: Engine,
34    url_regex: Option<&str>,
35) -> Result<Vec<TargetInfo>> {
36    let pattern = url_regex.map(Regex::new).transpose()?;
37    let raw = match engine {
38        Engine::Cdp => list_cdp(endpoint).await?,
39        Engine::Bidi => list_bidi(endpoint).await?,
40    };
41    Ok(raw
42        .into_iter()
43        .filter(|t| pattern.as_ref().map_or(true, |re| re.is_match(&t.url)))
44        .collect())
45}
46
47async fn list_cdp(endpoint: &str) -> Result<Vec<TargetInfo>> {
48    let client = open_cdp(endpoint).await?;
49    let targets = client.list_targets().await?;
50    client.close().await;
51    Ok(targets
52        .into_iter()
53        .filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("page"))
54        .map(|t| TargetInfo {
55            id: t
56                .get("targetId")
57                .and_then(|v| v.as_str())
58                .unwrap_or_default()
59                .to_string(),
60            url: t
61                .get("url")
62                .and_then(|v| v.as_str())
63                .unwrap_or_default()
64                .to_string(),
65            title: t
66                .get("title")
67                .and_then(|v| v.as_str())
68                .unwrap_or_default()
69                .to_string(),
70            kind: "page".to_string(),
71        })
72        .collect())
73}
74
75async fn list_bidi(endpoint: &str) -> Result<Vec<TargetInfo>> {
76    let client = open_bidi(endpoint).await?;
77    client.session_new().await?;
78    let tree = client.send("browsingContext.getTree", json!({})).await?;
79    let contexts = tree
80        .get("contexts")
81        .and_then(|v| v.as_array())
82        .cloned()
83        .unwrap_or_default();
84    Ok(contexts
85        .into_iter()
86        .map(|c| TargetInfo {
87            id: c
88                .get("context")
89                .and_then(|v| v.as_str())
90                .unwrap_or_default()
91                .to_string(),
92            url: c
93                .get("url")
94                .and_then(|v| v.as_str())
95                .unwrap_or_default()
96                .to_string(),
97            title: c
98                .get("title")
99                .and_then(|v| v.as_str())
100                .unwrap_or_default()
101                .to_string(),
102            kind: "context".to_string(),
103        })
104        .collect())
105}
106
107pub(crate) async fn open_cdp(endpoint: &str) -> Result<CdpClient> {
108    if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
109        CdpClient::connect(endpoint).await
110    } else {
111        CdpClient::connect_http(endpoint).await
112    }
113}
114
115pub(crate) async fn open_bidi(endpoint: &str) -> Result<BidiClient> {
116    if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
117        BidiClient::connect(endpoint).await
118    } else {
119        let client = reqwest::Client::new();
120        let v: Value = client
121            .get(format!("{}/json/version", endpoint.trim_end_matches('/')))
122            .send()
123            .await?
124            .json()
125            .await?;
126        let ws = v
127            .get("webSocketDebuggerUrl")
128            .and_then(|v| v.as_str())
129            .ok_or_else(|| anyhow!("no webSocketDebuggerUrl"))?
130            .to_string();
131        BidiClient::connect(&ws).await
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    use futures_util::{SinkExt, StreamExt};
139    use tokio_tungstenite::tungstenite::Message;
140
141    async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
142        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
143        let addr = listener.local_addr().unwrap();
144        tokio::spawn(async move {
145            let (stream, _) = listener.accept().await.unwrap();
146            let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
147            while let Some(Ok(Message::Text(t))) = ws.next().await {
148                let req: Value = serde_json::from_str(&t).unwrap();
149                let id = req["id"].as_u64().unwrap();
150                let method = req["method"].as_str().unwrap_or("");
151                let result = if method == "Target.getTargets" {
152                    json!({"targetInfos": targets.clone()})
153                } else {
154                    json!({})
155                };
156                let resp = json!({"id": id, "result": result});
157                ws.send(Message::Text(resp.to_string())).await.unwrap();
158            }
159        });
160        format!("ws://{addr}")
161    }
162
163    #[tokio::test]
164    async fn list_cdp_filters_pages_only() {
165        let url = spawn_cdp_mock(vec![
166            json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
167            json!({"targetId":"b","type":"iframe","url":"https://example.com/","title":""}),
168            json!({"targetId":"c","type":"page","url":"https://other.test/","title":"Other"}),
169        ])
170        .await;
171        let out = list(&url, Engine::Cdp, None).await.unwrap();
172        assert_eq!(out.len(), 2);
173        assert_eq!(out[0].id, "a");
174        assert_eq!(out[1].id, "c");
175    }
176
177    #[tokio::test]
178    async fn list_cdp_applies_url_regex() {
179        let url = spawn_cdp_mock(vec![
180            json!({"targetId":"a","type":"page","url":"https://example.com/","title":"Ex"}),
181            json!({"targetId":"c","type":"page","url":"https://other.test/","title":"Other"}),
182        ])
183        .await;
184        let out = list(&url, Engine::Cdp, Some(r"example\.com"))
185            .await
186            .unwrap();
187        assert_eq!(out.len(), 1);
188        assert_eq!(out[0].url, "https://example.com/");
189    }
190
191    #[tokio::test]
192    async fn list_propagates_invalid_regex() {
193        let err = list("ws://127.0.0.1:1", Engine::Cdp, Some("(invalid"))
194            .await
195            .unwrap_err();
196        assert!(err.to_string().to_lowercase().contains("regex"));
197    }
198}