1use std::sync::Arc;
9
10use anyhow::{anyhow, Result};
11use regex::Regex;
12use serde_json::{json, Value};
13
14use crate::bidi::BidiClient;
15use crate::cdp::CdpClient;
16use crate::detect::Engine;
17use crate::session::targets::{open_bidi, open_cdp};
18
19pub enum PageSession {
22 Cdp(CdpPage),
23 Bidi(BidiPage),
27}
28
29pub struct CdpPage {
30 pub client: CdpClient,
31 pub session_id: String,
32 pub target_id: String,
33}
34
35pub struct BidiPage {
36 pub client: Arc<BidiClient>,
37 pub context: String,
38}
39
40impl PageSession {
41 pub async fn attach(endpoint: &str, engine: Engine, url_regex: Option<&str>) -> Result<Self> {
47 let pattern = url_regex.map(Regex::new).transpose()?;
48 match engine {
49 Engine::Cdp => {
50 let client = open_cdp(endpoint).await?;
51 let target_id = pick_cdp_page(&client, pattern.as_ref()).await?;
52 let session_id = client.attach_to_target(&target_id).await?;
53 Ok(PageSession::Cdp(CdpPage {
54 client,
55 session_id,
56 target_id,
57 }))
58 }
59 Engine::Bidi => {
60 let client = Arc::new(open_bidi(endpoint).await?);
61 client.session_new().await?;
62 let context = pick_bidi_context(&client, pattern.as_ref()).await?;
63 Ok(PageSession::Bidi(BidiPage { client, context }))
64 }
65 }
66 }
67
68 pub async fn from_bidi_cache(client: Arc<BidiClient>, url_regex: Option<&str>) -> Result<Self> {
74 let pattern = url_regex.map(Regex::new).transpose()?;
75 let context = pick_bidi_context(&client, pattern.as_ref()).await?;
76 Ok(PageSession::Bidi(BidiPage { client, context }))
77 }
78
79 pub async fn evaluate(&self, expression: &str, await_promise: bool) -> Result<Value> {
85 match self {
86 PageSession::Cdp(p) => {
87 let v = p
88 .client
89 .send_with_session(
90 "Runtime.evaluate",
91 json!({
92 "expression": expression,
93 "returnByValue": true,
94 "awaitPromise": await_promise,
95 }),
96 Some(&p.session_id),
97 )
98 .await?;
99 Ok(v["result"]["value"].clone())
100 }
101 PageSession::Bidi(p) => {
102 let _ = await_promise; let v = p.client.script_evaluate(&p.context, expression).await?;
104 Ok(v["result"]["value"].clone())
105 }
106 }
107 }
108
109 pub async fn navigate(&self, url: &str) -> Result<()> {
111 match self {
112 PageSession::Cdp(p) => {
113 p.client
114 .send_with_session("Page.navigate", json!({"url": url}), Some(&p.session_id))
115 .await?;
116 Ok(())
117 }
118 PageSession::Bidi(p) => {
119 p.client.browsing_context_navigate(&p.context, url).await?;
120 Ok(())
121 }
122 }
123 }
124
125 pub async fn screenshot(&self, full_page: bool) -> Result<String> {
127 match self {
128 PageSession::Cdp(p) => {
129 let v = p
130 .client
131 .send_with_session(
132 "Page.captureScreenshot",
133 json!({
134 "format": "png",
135 "captureBeyondViewport": full_page,
136 }),
137 Some(&p.session_id),
138 )
139 .await?;
140 v["data"]
141 .as_str()
142 .map(|s| s.to_string())
143 .ok_or_else(|| anyhow!("no screenshot data"))
144 }
145 PageSession::Bidi(p) => {
146 let _ = full_page; p.client
148 .browsing_context_capture_screenshot(&p.context)
149 .await
150 }
151 }
152 }
153
154 pub fn engine(&self) -> Engine {
156 match self {
157 PageSession::Cdp(_) => Engine::Cdp,
158 PageSession::Bidi(_) => Engine::Bidi,
159 }
160 }
161
162 pub async fn close(self) {
165 match self {
166 PageSession::Cdp(p) => p.client.close().await,
167 PageSession::Bidi(_) => {}
168 }
169 }
170}
171
172async fn pick_cdp_page(client: &CdpClient, pattern: Option<&Regex>) -> Result<String> {
173 let targets = client.list_targets().await?;
174 let mut pages = targets
175 .iter()
176 .filter(|t| t.get("type").and_then(|v| v.as_str()) == Some("page"));
177 let pick = if let Some(re) = pattern {
178 pages
179 .find(|t| {
180 t.get("url")
181 .and_then(|v| v.as_str())
182 .is_some_and(|u| re.is_match(u))
183 })
184 .ok_or_else(|| anyhow!("no CDP page target matched URL regex"))?
185 } else {
186 pages
187 .next()
188 .ok_or_else(|| anyhow!("no page target found"))?
189 };
190 pick.get("targetId")
191 .and_then(|v| v.as_str())
192 .map(|s| s.to_string())
193 .ok_or_else(|| anyhow!("targetId missing from page target"))
194}
195
196async fn pick_bidi_context(client: &BidiClient, pattern: Option<&Regex>) -> Result<String> {
197 let tree = client.send("browsingContext.getTree", json!({})).await?;
198 let contexts = tree
199 .get("contexts")
200 .and_then(|v| v.as_array())
201 .ok_or_else(|| anyhow!("no contexts in browsingContext.getTree"))?;
202 if let Some(re) = pattern {
203 for c in contexts {
204 let url = c.get("url").and_then(|v| v.as_str()).unwrap_or("");
205 if re.is_match(url) {
206 return c
207 .get("context")
208 .and_then(|v| v.as_str())
209 .map(|s| s.to_string())
210 .ok_or_else(|| anyhow!("no context id"));
211 }
212 }
213 Err(anyhow!("no BiDi context matched URL regex"))
214 } else {
215 contexts
216 .first()
217 .and_then(|c| c.get("context").and_then(|v| v.as_str()))
218 .map(|s| s.to_string())
219 .ok_or_else(|| anyhow!("no top-level browsing context"))
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use futures_util::{SinkExt, StreamExt};
227 use tokio_tungstenite::tungstenite::Message;
228
229 async fn spawn_cdp_mock(targets: Vec<Value>) -> String {
230 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
231 let addr = listener.local_addr().unwrap();
232 tokio::spawn(async move {
233 let (stream, _) = listener.accept().await.unwrap();
234 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
235 while let Some(Ok(Message::Text(t))) = ws.next().await {
236 let req: Value = serde_json::from_str(&t).unwrap();
237 let id = req["id"].as_u64().unwrap();
238 let method = req["method"].as_str().unwrap_or("");
239 let result = match method {
240 "Target.getTargets" => json!({"targetInfos": targets.clone()}),
241 "Target.attachToTarget" => json!({"sessionId": "S1"}),
242 "Runtime.evaluate" => json!({"result": {"value": "ok"}}),
243 "Page.navigate" => json!({}),
244 "Page.captureScreenshot" => json!({"data": "PNGDATA"}),
245 _ => json!({}),
246 };
247 let resp = json!({"id": id, "result": result});
248 ws.send(Message::Text(resp.to_string())).await.unwrap();
249 }
250 });
251 format!("ws://{addr}")
252 }
253
254 #[tokio::test]
255 async fn attach_cdp_picks_first_page_when_no_regex() {
256 let url = spawn_cdp_mock(vec![
257 json!({"targetId":"a","type":"page","url":"https://example.com/"}),
258 json!({"targetId":"b","type":"page","url":"https://other.test/"}),
259 ])
260 .await;
261 let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
262 match s {
263 PageSession::Cdp(p) => {
264 assert_eq!(p.target_id, "a");
265 assert_eq!(p.session_id, "S1");
266 }
267 _ => panic!("expected CDP"),
268 }
269 }
270
271 #[tokio::test]
272 async fn attach_cdp_url_regex_selects_matching() {
273 let url = spawn_cdp_mock(vec![
274 json!({"targetId":"a","type":"page","url":"https://example.com/"}),
275 json!({"targetId":"b","type":"page","url":"https://other.test/"}),
276 ])
277 .await;
278 let s = PageSession::attach(&url, Engine::Cdp, Some(r"other"))
279 .await
280 .unwrap();
281 match s {
282 PageSession::Cdp(p) => assert_eq!(p.target_id, "b"),
283 _ => panic!("expected CDP"),
284 }
285 }
286
287 #[tokio::test]
288 async fn attach_cdp_url_regex_no_match_errors() {
289 let url = spawn_cdp_mock(vec![
290 json!({"targetId":"a","type":"page","url":"https://example.com/"}),
291 ])
292 .await;
293 let err = match PageSession::attach(&url, Engine::Cdp, Some("nomatch")).await {
294 Ok(_) => panic!("expected error"),
295 Err(e) => e,
296 };
297 assert!(err.to_string().contains("no CDP page target matched"));
298 }
299
300 #[tokio::test]
301 async fn evaluate_round_trip_cdp() {
302 let url = spawn_cdp_mock(vec![
303 json!({"targetId":"a","type":"page","url":"https://example.com/"}),
304 ])
305 .await;
306 let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
307 let v = s.evaluate("1+1", false).await.unwrap();
308 assert_eq!(v, json!("ok"));
309 s.close().await;
310 }
311
312 #[tokio::test]
313 async fn screenshot_round_trip_cdp() {
314 let url = spawn_cdp_mock(vec![
315 json!({"targetId":"a","type":"page","url":"https://example.com/"}),
316 ])
317 .await;
318 let s = PageSession::attach(&url, Engine::Cdp, None).await.unwrap();
319 let b64 = s.screenshot(false).await.unwrap();
320 assert_eq!(b64, "PNGDATA");
321 s.close().await;
322 }
323}