browser_control/session/backend.rs
1//! Engine-agnostic tab backend used by the named-tab registry and the
2//! scratch-recovery wrapper.
3//!
4//! Tab operations on Chromium-family browsers go through CDP
5//! (`Target.*` + per-target session attach), and on Firefox via WebDriver
6//! BiDi (`browsingContext.*` + `script.evaluate`). The named-tab CLI and
7//! the scratch-tab recovery wrapper are engine-independent and just need
8//! these four primitives:
9//!
10//! - **create** a fresh tab at a URL (`about:blank` if unspecified).
11//! - **close** a tab by its engine-specific id.
12//! - **navigate** an existing tab to a URL.
13//! - **list** every live top-level tab id.
14//!
15//! Plus one more for the eval/fetch path:
16//!
17//! - **evaluate** a JS expression in a tab, returning the result value.
18//!
19//! `target_id` is an opaque `String` on both engines — CDP's `targetId`
20//! and BiDi's `context` are both opaque ids the registry stores verbatim.
21
22use std::collections::HashSet;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use anyhow::{anyhow, Result};
27use serde_json::{json, Value};
28
29use crate::bidi::BidiClient;
30use crate::cdp::CdpClient;
31use crate::cli::cookies::{normalize_bidi, normalize_cdp, NormalCookie};
32use crate::errors::SessionError;
33use crate::session::freshness;
34use crate::session::targets::{BidiContext, CdpTarget};
35
36/// Wall-clock bound for `navigate`/`screenshot`. `evaluate` takes its
37/// timeout from the caller (op-specific budgets), but navigate/screenshot
38/// have no caller-supplied budget, so they default to this. Picked below
39/// the 30s CDP `REQUEST_TIMEOUT` so a wedged op surfaces as a typed,
40/// *recoverable* `TabHung`/`TabCrashed` before the client's generic
41/// "CDP request timed out" string (which is not in the recoverable needle
42/// list) can fire and defeat recover-once.
43const NAV_OP_TIMEOUT: Duration = Duration::from_secs(20);
44
45/// Engine-agnostic tab operations. Two variants because CDP and BiDi
46/// have different protocols and clients; the methods abstract over the
47/// difference.
48#[derive(Clone)]
49pub enum TabBackend {
50 Cdp(Arc<CdpClient>),
51 Bidi(Arc<BidiClient>),
52}
53
54/// Lightweight view of a live tab returned by [`TabBackend::live_targets`].
55/// Used by `tab list --all` to merge the named-tab registry with the
56/// browser's current target/context set.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct LiveTarget {
59 pub id: String,
60 pub url: String,
61 pub title: String,
62}
63
64impl TabBackend {
65 /// Create a fresh top-level tab. Returns the engine-specific id
66 /// (CDP `targetId`, BiDi `context`) the registry stores verbatim.
67 /// `url` defaults to `about:blank`.
68 pub async fn create_tab(&self, url: &str) -> Result<String> {
69 let url = if url.is_empty() { "about:blank" } else { url };
70 match self {
71 TabBackend::Cdp(c) => {
72 let v = c.send("Target.createTarget", json!({ "url": url })).await?;
73 v.get("targetId")
74 .and_then(|x| x.as_str())
75 .map(String::from)
76 .ok_or_else(|| anyhow!("Target.createTarget returned no targetId"))
77 }
78 TabBackend::Bidi(c) => c.browsing_context_create(url).await,
79 }
80 }
81
82 /// Close a tab by id. Best-effort — both CDP and BiDi handle a
83 /// missing id gracefully, and the caller's intent ("this tab is
84 /// gone") is satisfied either way.
85 pub async fn close_tab(&self, target_id: &str) -> Result<()> {
86 match self {
87 TabBackend::Cdp(c) => {
88 let _ = c
89 .send("Target.closeTarget", json!({ "targetId": target_id }))
90 .await?;
91 Ok(())
92 }
93 TabBackend::Bidi(c) => c.browsing_context_close(target_id).await,
94 }
95 }
96
97 /// Navigate an existing tab to `url`. CDP requires attaching a
98 /// transient session; BiDi takes the context id directly.
99 pub async fn navigate(&self, target_id: &str, url: &str) -> Result<()> {
100 match self {
101 TabBackend::Cdp(c) => {
102 let attach = c
103 .send(
104 "Target.attachToTarget",
105 json!({ "targetId": target_id, "flatten": true }),
106 )
107 .await?;
108 let session_id = attach
109 .get("sessionId")
110 .and_then(|v| v.as_str())
111 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
112 .to_string();
113 // Enable the Inspector domain so `Inspector.targetCrashed`
114 // is delivered while the navigate is in flight. Best-effort,
115 // same rationale as `evaluate`.
116 let _ = c
117 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
118 .await;
119 let inner = async {
120 c.send_with_session("Page.navigate", json!({ "url": url }), Some(&session_id))
121 .await
122 };
123 // Bound by timeout + renderer-crash detection so a wedged
124 // navigate surfaces as recoverable `TabHung`/`TabCrashed`
125 // (recover-once), not a 30s non-recoverable client timeout.
126 let result = crate::session::crash::evaluate_with_crash_detection(
127 c,
128 target_id,
129 Some(&session_id),
130 inner,
131 Some(NAV_OP_TIMEOUT),
132 )
133 .await;
134 let _ = c
135 .send(
136 "Target.detachFromTarget",
137 json!({ "sessionId": session_id }),
138 )
139 .await;
140 result?;
141 Ok(())
142 }
143 TabBackend::Bidi(c) => {
144 // BiDi has no crash event; a wedged navigate must still be
145 // bounded so it surfaces as recoverable `TabHung` rather
146 // than the 30s client `SEND_TIMEOUT`. A dead context comes
147 // back as `no such context` which the `TargetGone`
148 // classifier already treats as recoverable.
149 let fut = c.browsing_context_navigate(target_id, url);
150 match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
151 Ok(r) => r.map(|_| ()),
152 Err(_) => Err(SessionError::TabHung {
153 target_id: Some(target_id.to_string()),
154 url: Some(url.to_string()),
155 timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
156 hint: "op-timeout",
157 }
158 .into()),
159 }
160 }
161 }
162 }
163
164 /// Reload an old HTTP(S) tab before reading auth-sensitive page state.
165 ///
166 /// The age is measured from the document's `performance.timeOrigin`.
167 /// Non-web pages such as `about:blank` are left untouched.
168 pub async fn ensure_fresh(&self, target_id: &str, max_age: Duration) -> Result<()> {
169 let info_value = self
170 .evaluate(
171 target_id,
172 freshness::PAGE_FRESHNESS_EXPR,
173 false,
174 freshness::CHECK_TIMEOUT,
175 )
176 .await?;
177 let info = freshness::parse_page_freshness(info_value)?;
178 if !info.should_reload(max_age) {
179 return Ok(());
180 }
181
182 tracing::info!(
183 target = "session",
184 target_id = %target_id,
185 url = %info.href,
186 age_ms = info.age_ms,
187 max_age_ms = max_age.as_millis(),
188 "reloading stale tab before reading page context"
189 );
190 self.navigate(target_id, &info.href).await?;
191 self.wait_until_ready(target_id).await
192 }
193
194 async fn wait_until_ready(&self, target_id: &str) -> Result<()> {
195 let deadline = Instant::now() + freshness::RELOAD_READY_TIMEOUT;
196 loop {
197 let value = self
198 .evaluate(
199 target_id,
200 freshness::READY_STATE_EXPR,
201 false,
202 freshness::CHECK_TIMEOUT,
203 )
204 .await?;
205 if freshness::is_ready(&value) {
206 return Ok(());
207 }
208 if Instant::now() >= deadline {
209 tracing::warn!(
210 target = "session",
211 target_id = %target_id,
212 "tab reload did not reach document.readyState=complete before continuing"
213 );
214 return Ok(());
215 }
216 tokio::time::sleep(freshness::READY_POLL_INTERVAL).await;
217 }
218 }
219
220 /// Snapshot of every live top-level tab id in the browser.
221 /// Used by the registry's sweep-on-read to drop rows whose target
222 /// no longer exists.
223 pub async fn live_target_ids(&self) -> Result<HashSet<String>> {
224 Ok(self
225 .live_targets()
226 .await?
227 .into_iter()
228 .map(|t| t.id)
229 .collect())
230 }
231
232 /// Snapshot of every live top-level tab with id + URL + title. Used by
233 /// `tab list --all` to merge the named-tab registry with the
234 /// browser's view of the world. CDP filters to `type == "page"`; BiDi
235 /// returns every top-level browsing context.
236 pub async fn live_targets(&self) -> Result<Vec<LiveTarget>> {
237 match self {
238 TabBackend::Cdp(c) => {
239 let v: Value = c.send("Target.getTargets", json!({})).await?;
240 let arr = v
241 .get("targetInfos")
242 .and_then(|x| x.as_array())
243 .cloned()
244 .unwrap_or_default();
245 Ok(CdpTarget::pages(&arr)
246 .map(|t| LiveTarget {
247 id: t.id,
248 url: t.url,
249 title: t.title,
250 })
251 .collect())
252 }
253 TabBackend::Bidi(c) => {
254 let v: Value = c.send("browsingContext.getTree", json!({})).await?;
255 // BiDi getTree doesn't expose page titles directly on the
256 // context node; leave blank for now.
257 Ok(BidiContext::from_tree(&v)
258 .into_iter()
259 .map(|ctx| LiveTarget {
260 id: ctx.context,
261 url: ctx.url,
262 title: String::new(),
263 })
264 .collect())
265 }
266 }
267 }
268
269 /// Resolve a target whose document origin matches `url`'s origin,
270 /// reusing a live tab already on that origin if one exists and creating
271 /// one rooted at the origin otherwise. Returns the engine-specific id.
272 ///
273 /// This is the routing primitive for `browser_fetch`: running the
274 /// in-page fetch from a same-origin document is what lets cookies and
275 /// credentials propagate and lets the response bypass CORS. Routing a
276 /// fetch through an `about:blank` scratch tab (this backend's default
277 /// active tab) gives it an opaque origin, which silently breaks
278 /// authenticated and CORS-sensitive requests — see `cli::fetch`'s
279 /// origin-bound path for the same contract.
280 pub async fn resolve_or_create_for_origin(&self, url: &str) -> Result<String> {
281 let want = url::Url::parse(url).map_err(|e| anyhow!("invalid fetch URL `{url}`: {e}"))?;
282 for t in self.live_targets().await? {
283 if let Ok(parsed) = url::Url::parse(&t.url) {
284 if crate::session::attach::same_origin(&parsed, &want) {
285 return Ok(t.id);
286 }
287 }
288 }
289 let root = crate::session::attach::origin_root_url(&want);
290 self.create_tab(&root).await
291 }
292
293 /// Evaluate `expression` in `target_id`'s main world, returning the
294 /// raw result value (after `returnByValue`). Bounded by `timeout`;
295 /// expiry returns typed [`SessionError::TabHung`].
296 ///
297 /// CDP path attaches a transient session, calls `Runtime.evaluate`,
298 /// detaches. BiDi path calls `script.evaluate` against the context.
299 /// On BiDi, `await_promise` is ignored — BiDi always awaits per
300 /// `script.evaluate` semantics.
301 pub async fn evaluate(
302 &self,
303 target_id: &str,
304 expression: &str,
305 await_promise: bool,
306 timeout: Duration,
307 ) -> Result<Value> {
308 match self {
309 TabBackend::Cdp(c) => {
310 let attach = c
311 .send(
312 "Target.attachToTarget",
313 json!({ "targetId": target_id, "flatten": true }),
314 )
315 .await?;
316 let session_id = attach
317 .get("sessionId")
318 .and_then(|v| v.as_str())
319 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
320 .to_string();
321 // Enable the Inspector domain on the attached session so
322 // `Inspector.targetCrashed` is delivered while the
323 // evaluate is in flight. Best-effort: older Chromium
324 // builds and headless variants may answer with an
325 // empty result but never raise — failing the enable
326 // would silently mute crash detection, so we proceed.
327 let _ = c
328 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
329 .await;
330 let inner = async {
331 let v = c
332 .send_with_session(
333 "Runtime.evaluate",
334 json!({
335 "expression": expression,
336 "returnByValue": true,
337 "awaitPromise": await_promise,
338 }),
339 Some(&session_id),
340 )
341 .await?;
342 Ok::<Value, anyhow::Error>(v["result"]["value"].clone())
343 };
344 let value = crate::session::crash::evaluate_with_crash_detection(
345 c,
346 target_id,
347 Some(&session_id),
348 inner,
349 Some(timeout),
350 )
351 .await;
352 let _ = c
353 .send(
354 "Target.detachFromTarget",
355 json!({ "sessionId": session_id }),
356 )
357 .await;
358 value
359 }
360 TabBackend::Bidi(c) => {
361 let _ = await_promise; // BiDi always awaits
362 let fut = c.script_evaluate(target_id, expression);
363 match tokio::time::timeout(timeout, fut).await {
364 Ok(Ok(v)) => Ok(v["result"]["value"].clone()),
365 Ok(Err(e)) => Err(e),
366 Err(_) => Err(SessionError::TabHung {
367 target_id: Some(target_id.to_string()),
368 url: None,
369 timeout_ms: timeout.as_millis() as u64,
370 hint: "op-timeout",
371 }
372 .into()),
373 }
374 }
375 }
376 }
377
378 /// Capture a PNG screenshot of `target_id` and return base64-encoded
379 /// bytes.
380 ///
381 /// CDP path attaches a transient session, calls
382 /// `Page.captureScreenshot({format:"png", captureBeyondViewport:full_page})`,
383 /// detaches. BiDi path calls `browsingContext.captureScreenshot` —
384 /// the BiDi protocol always captures the viewport (no `full_page`
385 /// equivalent), so `full_page` is honoured only on CDP.
386 ///
387 /// When `clip` is `Some({x, y, width, height})` (document coordinates, as
388 /// produced by [`crate::dom::scripts::GET_CLIP_RECT_JS`]) the capture is
389 /// restricted to that rectangle, which takes precedence over `full_page`.
390 pub async fn screenshot(
391 &self,
392 target_id: &str,
393 full_page: bool,
394 clip: Option<Value>,
395 ) -> Result<String> {
396 match self {
397 TabBackend::Cdp(c) => {
398 let attach = c
399 .send(
400 "Target.attachToTarget",
401 json!({ "targetId": target_id, "flatten": true }),
402 )
403 .await?;
404 let session_id = attach
405 .get("sessionId")
406 .and_then(|v| v.as_str())
407 .ok_or_else(|| anyhow!("attachToTarget returned no sessionId"))?
408 .to_string();
409 // Enable the Inspector domain so `Inspector.targetCrashed`
410 // is delivered while the capture is in flight. Best-effort,
411 // same rationale as `evaluate`.
412 let _ = c
413 .send_with_session("Inspector.enable", json!({}), Some(&session_id))
414 .await;
415 // A clip rectangle lives outside the viewport in the general
416 // case (the element was scrolled into view by the caller, but
417 // may still be taller than the viewport), so force
418 // `captureBeyondViewport` whenever clipping.
419 let mut params = json!({
420 "format": "png",
421 "captureBeyondViewport": full_page || clip.is_some(),
422 });
423 if let Some(rect) = &clip {
424 params["clip"] = json!({
425 "x": rect["x"],
426 "y": rect["y"],
427 "width": rect["width"],
428 "height": rect["height"],
429 "scale": 1,
430 });
431 }
432 let inner = async {
433 c.send_with_session("Page.captureScreenshot", params, Some(&session_id))
434 .await
435 };
436 // Bound by timeout + renderer-crash detection so a wedged
437 // capture surfaces as recoverable `TabHung`/`TabCrashed`,
438 // not a 30s non-recoverable client timeout.
439 let v = crate::session::crash::evaluate_with_crash_detection(
440 c,
441 target_id,
442 Some(&session_id),
443 inner,
444 Some(NAV_OP_TIMEOUT),
445 )
446 .await;
447 let _ = c
448 .send(
449 "Target.detachFromTarget",
450 json!({ "sessionId": session_id }),
451 )
452 .await;
453 let v = v?;
454 v["data"]
455 .as_str()
456 .map(|s| s.to_string())
457 .ok_or_else(|| anyhow!("Page.captureScreenshot returned no data"))
458 }
459 TabBackend::Bidi(c) => {
460 let _ = full_page; // BiDi captures the viewport by default
461 let fut = c.browsing_context_capture_screenshot(target_id, clip);
462 match tokio::time::timeout(NAV_OP_TIMEOUT, fut).await {
463 Ok(r) => r,
464 Err(_) => Err(SessionError::TabHung {
465 target_id: Some(target_id.to_string()),
466 url: None,
467 timeout_ms: NAV_OP_TIMEOUT.as_millis() as u64,
468 hint: "op-timeout",
469 }
470 .into()),
471 }
472 }
473 }
474 }
475
476 /// Fetch the full cookie jar through this backend's *existing* client,
477 /// normalised across engines. Unlike `cli::cookies::fetch_cookies`,
478 /// this reuses the already-open session instead of opening a fresh
479 /// one — required on Firefox, where BiDi permits only one session per
480 /// browser, so a second `session.new` against a server-held browser
481 /// fails or races. Cookies are browser-wide on both engines (CDP
482 /// `Network.getAllCookies` / BiDi `storage.getCookies`), so no target
483 /// id is needed.
484 pub(crate) async fn cookies(&self) -> Result<Vec<NormalCookie>> {
485 match self {
486 TabBackend::Cdp(c) => {
487 let v = c.send("Network.getAllCookies", json!({})).await?;
488 let arr = v
489 .get("cookies")
490 .and_then(|x| x.as_array())
491 .ok_or_else(|| anyhow!("CDP Network.getAllCookies: missing `cookies` array"))?;
492 Ok(arr.iter().map(normalize_cdp).collect())
493 }
494 TabBackend::Bidi(c) => {
495 let v = c.send("storage.getCookies", json!({})).await?;
496 let arr = v
497 .get("cookies")
498 .and_then(|x| x.as_array())
499 .ok_or_else(|| anyhow!("BiDi storage.getCookies: missing `cookies` array"))?;
500 Ok(arr.iter().map(normalize_bidi).collect())
501 }
502 }
503 }
504}
505
506/// Open the right [`TabBackend`] for a resolved browser endpoint, taking
507/// care of BiDi's `session.new` handshake. The returned backend is `Clone`
508/// and owns its underlying client via `Arc`.
509pub async fn open_backend(endpoint: &str, engine: crate::detect::Engine) -> Result<TabBackend> {
510 match engine {
511 crate::detect::Engine::Cdp => {
512 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
513 CdpClient::connect(endpoint).await?
514 } else {
515 CdpClient::connect_http(endpoint).await?
516 };
517 Ok(TabBackend::Cdp(Arc::new(client)))
518 }
519 crate::detect::Engine::Bidi => {
520 let client = if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
521 BidiClient::connect(endpoint).await?
522 } else {
523 // HTTP discovery for BiDi: fetch /json/version, extract
524 // webSocketDebuggerUrl, then connect. Firefox geckodriver
525 // exposes /session via WebDriver classic but BiDi sessions
526 // need the WS URL — same flow as CDP.
527 let base = endpoint.trim_end_matches('/');
528 let url = format!("{base}/json/version");
529 let client = reqwest::Client::builder()
530 .timeout(Duration::from_secs(5))
531 .build()?;
532 let resp: Value = client.get(&url).send().await?.json().await?;
533 let ws = resp
534 .get("webSocketDebuggerUrl")
535 .and_then(|x| x.as_str())
536 .ok_or_else(|| anyhow!("webSocketDebuggerUrl missing from {url}"))?
537 .to_string();
538 BidiClient::connect(&ws).await?
539 };
540 // BiDi requires session.new before any other call. Use the
541 // existing helper which handles "session already active" via
542 // session.end + retry.
543 client.session_new().await?;
544 Ok(TabBackend::Bidi(Arc::new(client)))
545 }
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use futures_util::{SinkExt, StreamExt};
553 use std::sync::Arc;
554 use tokio::sync::{oneshot, Mutex};
555 use tokio_tungstenite::tungstenite::Message;
556
557 // CDP and BiDi each have their own mock-server tests in lower-level
558 // modules; these tests focus on the engine-agnostic behaviour of the
559 // backend wrapper.
560
561 async fn spawn_cdp_mock() -> (String, oneshot::Sender<()>) {
562 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
563 let addr = listener.local_addr().unwrap();
564 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
565 tokio::spawn(async move {
566 let (stream, _) = listener.accept().await.unwrap();
567 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
568 let mut next_target = 0u32;
569 let mut next_session = 0u32;
570 // target id -> last-known url, so getTargets can report a URL
571 // and origin resolution has something to match against.
572 let mut live = std::collections::HashMap::<String, String>::new();
573 // Sessions attach to a target; remember which so navigate can
574 // update the right target's url.
575 let mut sessions = std::collections::HashMap::<String, String>::new();
576 loop {
577 tokio::select! {
578 _ = &mut stop_rx => break,
579 msg = ws.next() => {
580 let msg = match msg {
581 Some(Ok(m)) => m,
582 _ => break,
583 };
584 if let Message::Text(t) = msg {
585 let req: Value = serde_json::from_str(&t).unwrap();
586 let id = req["id"].as_u64().unwrap();
587 let method = req["method"].as_str().unwrap_or("");
588 let result = match method {
589 "Target.createTarget" => {
590 next_target += 1;
591 let tid = format!("T{next_target}");
592 let url = req
593 .pointer("/params/url")
594 .and_then(|v| v.as_str())
595 .unwrap_or("")
596 .to_string();
597 live.insert(tid.clone(), url);
598 json!({"targetId": tid})
599 }
600 "Target.closeTarget" => {
601 if let Some(tid) = req
602 .pointer("/params/targetId")
603 .and_then(|v| v.as_str())
604 {
605 live.remove(tid);
606 }
607 json!({"success": true})
608 }
609 "Target.attachToTarget" => {
610 next_session += 1;
611 let sid = format!("S{next_session}");
612 if let Some(tid) = req
613 .pointer("/params/targetId")
614 .and_then(|v| v.as_str())
615 {
616 sessions.insert(sid.clone(), tid.to_string());
617 }
618 json!({"sessionId": sid})
619 }
620 "Target.detachFromTarget" => json!({}),
621 "Page.navigate" => {
622 // Update the attached target's url so a
623 // later getTargets reflects the navigation.
624 if let (Some(sid), Some(url)) = (
625 req.pointer("/sessionId").and_then(|v| v.as_str()),
626 req.pointer("/params/url").and_then(|v| v.as_str()),
627 ) {
628 if let Some(tid) = sessions.get(sid) {
629 live.insert(tid.clone(), url.to_string());
630 }
631 }
632 json!({})
633 }
634 "Runtime.evaluate" => json!({"result": {"value": 7}}),
635 "Target.getTargets" => {
636 let infos: Vec<Value> = live
637 .iter()
638 .map(|(tid, url)| json!({"targetId": tid, "type": "page", "url": url}))
639 .collect();
640 json!({"targetInfos": infos})
641 }
642 _ => json!({}),
643 };
644 let resp = json!({"id": id, "result": result});
645 ws.send(Message::Text(resp.to_string())).await.unwrap();
646 }
647 }
648 }
649 }
650 });
651 (format!("ws://{addr}"), stop_tx)
652 }
653
654 async fn spawn_bidi_mock() -> (String, oneshot::Sender<()>) {
655 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
656 let addr = listener.local_addr().unwrap();
657 let (stop_tx, mut stop_rx) = oneshot::channel::<()>();
658 tokio::spawn(async move {
659 let (stream, _) = listener.accept().await.unwrap();
660 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
661 let mut next_ctx = 0u32;
662 let mut live = std::collections::HashSet::<String>::new();
663 loop {
664 tokio::select! {
665 _ = &mut stop_rx => break,
666 msg = ws.next() => {
667 let msg = match msg {
668 Some(Ok(m)) => m,
669 _ => break,
670 };
671 if let Message::Text(t) = msg {
672 let req: Value = serde_json::from_str(&t).unwrap();
673 let id = req["id"].as_u64().unwrap();
674 let method = req["method"].as_str().unwrap_or("");
675 let result = match method {
676 "session.new" => json!({"sessionId": "S1", "capabilities": {}}),
677 "browsingContext.create" => {
678 next_ctx += 1;
679 let c = format!("C{next_ctx}");
680 live.insert(c.clone());
681 json!({"context": c})
682 }
683 "browsingContext.close" => {
684 if let Some(c) = req
685 .pointer("/params/context")
686 .and_then(|v| v.as_str())
687 {
688 live.remove(c);
689 }
690 json!({})
691 }
692 "browsingContext.navigate" => json!({"navigation": "N1"}),
693 "script.evaluate" => json!({"result": {"value": 9}}),
694 "browsingContext.getTree" => {
695 let contexts: Vec<Value> = live
696 .iter()
697 .map(|c| json!({"context": c, "url": "", "children": []}))
698 .collect();
699 json!({"contexts": contexts})
700 }
701 _ => json!({}),
702 };
703 // BiDi wire format uses {type, id, result} —
704 // not JSON-RPC `{id, result}` — per spec.
705 let resp = json!({"type": "success", "id": id, "result": result});
706 ws.send(Message::Text(resp.to_string())).await.unwrap();
707 }
708 }
709 }
710 }
711 });
712 (format!("ws://{addr}"), stop_tx)
713 }
714
715 async fn spawn_cdp_freshness_mock() -> (String, Arc<Mutex<Vec<String>>>) {
716 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
717 let addr = listener.local_addr().unwrap();
718 let navigations = Arc::new(Mutex::new(Vec::new()));
719 tokio::spawn({
720 let navigations = navigations.clone();
721 async move {
722 let (stream, _) = listener.accept().await.unwrap();
723 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
724 while let Some(Ok(Message::Text(t))) = ws.next().await {
725 let req: Value = serde_json::from_str(&t).unwrap();
726 let id = req["id"].as_u64().unwrap();
727 let method = req["method"].as_str().unwrap_or("");
728 let result = match method {
729 "Target.attachToTarget" => json!({"sessionId": "S1"}),
730 "Target.detachFromTarget" => json!({}),
731 "Inspector.enable" => json!({}),
732 "Runtime.evaluate" => {
733 let expression = req
734 .pointer("/params/expression")
735 .and_then(|v| v.as_str())
736 .unwrap_or("");
737 let value = if expression == freshness::PAGE_FRESHNESS_EXPR {
738 json!({
739 "href": "https://example.com/app",
740 "ageMs": 700_000.0,
741 "readyState": "complete"
742 })
743 } else if expression == freshness::READY_STATE_EXPR {
744 json!("complete")
745 } else {
746 json!(7)
747 };
748 json!({"result": {"value": value}})
749 }
750 "Page.navigate" => {
751 let url = req
752 .pointer("/params/url")
753 .and_then(|v| v.as_str())
754 .unwrap_or("")
755 .to_string();
756 navigations.lock().await.push(url);
757 json!({})
758 }
759 _ => json!({}),
760 };
761 let resp = json!({"id": id, "result": result});
762 ws.send(Message::Text(resp.to_string())).await.unwrap();
763 }
764 }
765 });
766 (format!("ws://{addr}"), navigations)
767 }
768
769 #[tokio::test]
770 async fn cdp_backend_create_close_navigate_list_evaluate() {
771 let (url, _stop) = spawn_cdp_mock().await;
772 let backend = open_backend(&url, crate::detect::Engine::Cdp)
773 .await
774 .unwrap();
775 let t1 = backend.create_tab("about:blank").await.unwrap();
776 assert_eq!(t1, "T1");
777 backend.navigate(&t1, "https://example.com/").await.unwrap();
778 let live = backend.live_target_ids().await.unwrap();
779 assert!(live.contains(&t1));
780 let v = backend
781 .evaluate(&t1, "1+1", false, Duration::from_secs(1))
782 .await
783 .unwrap();
784 assert_eq!(v, json!(7));
785 backend.close_tab(&t1).await.unwrap();
786 let live = backend.live_target_ids().await.unwrap();
787 assert!(!live.contains(&t1));
788 }
789
790 #[tokio::test]
791 async fn ensure_fresh_reloads_old_http_page() {
792 let (url, navigations) = spawn_cdp_freshness_mock().await;
793 let backend = open_backend(&url, crate::detect::Engine::Cdp)
794 .await
795 .unwrap();
796 backend
797 .ensure_fresh("T1", Duration::from_secs(600))
798 .await
799 .unwrap();
800 assert_eq!(
801 *navigations.lock().await,
802 vec!["https://example.com/app".to_string()]
803 );
804 }
805
806 #[tokio::test]
807 async fn resolve_for_origin_reuses_same_origin_tab() {
808 let (url, _stop) = spawn_cdp_mock().await;
809 let backend = open_backend(&url, crate::detect::Engine::Cdp)
810 .await
811 .unwrap();
812 // Open a tab and navigate it onto the target origin.
813 let t1 = backend.create_tab("about:blank").await.unwrap();
814 backend
815 .navigate(&t1, "https://example.com/login")
816 .await
817 .unwrap();
818 // A fetch to a different path on the same origin must reuse t1,
819 // not spin up a fresh tab.
820 let resolved = backend
821 .resolve_or_create_for_origin("https://example.com/api/v1")
822 .await
823 .unwrap();
824 assert_eq!(resolved, t1);
825 }
826
827 #[tokio::test]
828 async fn resolve_for_origin_creates_tab_when_no_match() {
829 let (url, _stop) = spawn_cdp_mock().await;
830 let backend = open_backend(&url, crate::detect::Engine::Cdp)
831 .await
832 .unwrap();
833 let t1 = backend.create_tab("about:blank").await.unwrap();
834 backend.navigate(&t1, "https://other.test/").await.unwrap();
835 // No live tab on example.com → a new one is created, rooted at the
836 // origin so the in-page fetch inherits that origin.
837 let resolved = backend
838 .resolve_or_create_for_origin("https://example.com/api")
839 .await
840 .unwrap();
841 assert_ne!(resolved, t1);
842 let live = backend.live_target_ids().await.unwrap();
843 assert!(live.contains(&resolved));
844 }
845
846 #[tokio::test]
847 async fn bidi_backend_create_close_navigate_list_evaluate() {
848 let (url, _stop) = spawn_bidi_mock().await;
849 let backend = open_backend(&url, crate::detect::Engine::Bidi)
850 .await
851 .unwrap();
852 let c1 = backend.create_tab("about:blank").await.unwrap();
853 assert_eq!(c1, "C1");
854 backend.navigate(&c1, "https://example.com/").await.unwrap();
855 let live = backend.live_target_ids().await.unwrap();
856 assert!(live.contains(&c1));
857 let v = backend
858 .evaluate(&c1, "1+1", false, Duration::from_secs(1))
859 .await
860 .unwrap();
861 assert_eq!(v, json!(9));
862 backend.close_tab(&c1).await.unwrap();
863 let live = backend.live_target_ids().await.unwrap();
864 assert!(!live.contains(&c1));
865 }
866}