browser_control/session/
targets.rs1use 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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
17pub struct TargetInfo {
18 pub id: String,
20 pub url: String,
22 pub title: String,
24 pub kind: String,
26}
27
28pub 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}