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