use pf_core::cas::BlobStore;
use pf_core::digest::Digest256;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
#[cfg(feature = "cdp-live")]
mod live;
#[cfg(feature = "cdp-live")]
pub use live::CdpClient;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum BrowserBlob {
#[serde(rename = "browser.cdp.v1")]
Cdp {
endpoint: String,
pages: Vec<PageSnapshot>,
},
#[serde(rename = "browser.unsupported.v1")]
Unsupported { reason: String },
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PageSnapshot {
pub target_id: String,
pub url: String,
pub title: String,
pub viewport_width: u32,
pub viewport_height: u32,
pub scroll_x: f64,
pub scroll_y: f64,
pub device_pixel_ratio: f64,
pub mhtml_digest: Digest256,
pub local_storage: std::collections::BTreeMap<String, String>,
pub session_storage: std::collections::BTreeMap<String, String>,
pub cookies_digest: Digest256,
}
pub struct BrowserCapture {
endpoint: Option<String>,
}
impl BrowserCapture {
#[must_use]
pub fn new(endpoint: Option<String>) -> Self {
Self { endpoint }
}
#[must_use]
pub fn from_env() -> Self {
Self {
endpoint: std::env::var("PF_BROWSER_CDP")
.ok()
.filter(|s| !s.is_empty()),
}
}
pub async fn capture(&self, blobs: &Arc<dyn BlobStore>) -> pf_core::Result<Digest256> {
let blob = match (&self.endpoint, cfg!(feature = "cdp-live")) {
(Some(endpoint), true) => self.capture_cdp(endpoint, blobs).await?,
(Some(_endpoint), false) => BrowserBlob::Unsupported {
reason: "pf-world built without `cdp-live` feature".into(),
},
(None, _) => BrowserBlob::Unsupported {
reason:
"no CDP endpoint configured (set PF_BROWSER_CDP or pass to BrowserCapture::new)"
.into(),
},
};
blobs.put(&serde_json::to_vec(&blob)?)
}
#[cfg(feature = "cdp-live")]
async fn capture_cdp(
&self,
endpoint: &str,
blobs: &Arc<dyn BlobStore>,
) -> pf_core::Result<BrowserBlob> {
let client = live::CdpClient::new(endpoint.to_owned());
match client.capture(blobs.clone()).await {
Ok(pages) => Ok(BrowserBlob::Cdp {
endpoint: endpoint.to_owned(),
pages,
}),
Err(e) => {
tracing::warn!(?e, "CDP capture failed; emitting Unsupported placeholder");
Ok(BrowserBlob::Unsupported {
reason: format!("CDP capture failed: {e}"),
})
}
}
}
#[cfg(not(feature = "cdp-live"))]
#[allow(clippy::unused_async, clippy::unused_self)]
async fn capture_cdp(
&self,
_endpoint: &str,
_blobs: &Arc<dyn BlobStore>,
) -> pf_core::Result<BrowserBlob> {
unreachable!("capture_cdp only called when cdp-live is on")
}
}
#[cfg(test)]
mod tests {
use super::*;
use pf_core::cas::MemBlobStore;
#[tokio::test]
async fn browser_capture_no_endpoint_returns_unsupported() {
let blobs: Arc<dyn BlobStore> = Arc::new(MemBlobStore::new());
let cap = BrowserCapture::new(None);
let digest = cap.capture(&blobs).await.unwrap();
let bytes = blobs.get(&digest).unwrap();
let blob: BrowserBlob = serde_json::from_slice(&bytes).unwrap();
match blob {
BrowserBlob::Unsupported { reason } => {
assert!(reason.contains("CDP endpoint"), "{reason}");
}
BrowserBlob::Cdp { .. } => panic!("expected Unsupported, got Cdp"),
}
}
#[test]
fn page_snapshot_round_trips_through_json() {
let p = PageSnapshot {
target_id: "T-1".into(),
url: "https://example.com".into(),
title: "Example".into(),
viewport_width: 1280,
viewport_height: 800,
scroll_x: 0.0,
scroll_y: 42.5,
device_pixel_ratio: 2.0,
mhtml_digest: Digest256::of(b"mhtml"),
local_storage: [("k".to_owned(), "v".to_owned())].into(),
session_storage: std::collections::BTreeMap::default(),
cookies_digest: Digest256::of(b"[]"),
};
let s = serde_json::to_string(&p).unwrap();
let p2: PageSnapshot = serde_json::from_str(&s).unwrap();
assert_eq!(p.url, p2.url);
assert_eq!(p.viewport_width, p2.viewport_width);
assert_eq!(p.local_storage.get("k").map(String::as_str), Some("v"));
}
}