#![cfg(feature = "cdp-backend")]
use async_trait::async_trait;
use bytes::Bytes;
use std::sync::Arc;
use url::Url;
use crate::error::Result;
use crate::render::chrome::page::Page;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScreenshotMode {
Viewport,
FullPage,
Element,
}
#[async_trait]
pub trait BrowserSessionLike: Send + Sync {
async fn goto(&self, url: &Url) -> Result<()>;
async fn eval_json(&self, expr: &str) -> Result<serde_json::Value>;
async fn screenshot(&self, mode: ScreenshotMode, selector: Option<&str>) -> Result<Bytes>;
async fn inject_script(&self, src: &str) -> Result<()>;
async fn url(&self) -> Result<Option<String>>;
async fn html(&self) -> Result<String>;
}
#[derive(Clone)]
pub struct BrowserSession {
page: Arc<Page>,
}
impl BrowserSession {
pub fn new(page: Arc<Page>) -> Self {
Self { page }
}
pub fn raw_page(&self) -> Arc<Page> {
self.page.clone()
}
}
#[async_trait]
impl BrowserSessionLike for BrowserSession {
async fn goto(&self, url: &Url) -> Result<()> {
use crate::render::chrome_protocol::cdp::browser_protocol::page::NavigateParams;
let params = NavigateParams::builder()
.url(url.to_string())
.build()
.map_err(|e| crate::Error::Render(format!("NavigateParams: {e}")))?;
self.page
.execute(params)
.await
.map_err(|e| crate::Error::Render(format!("navigate: {e}")))?;
Ok(())
}
async fn eval_json(&self, expr: &str) -> Result<serde_json::Value> {
let v = self
.page
.evaluate(expr)
.await
.map_err(|e| crate::Error::Render(format!("evaluate: {e}")))?;
Ok(v.value().cloned().unwrap_or(serde_json::Value::Null))
}
async fn screenshot(&self, mode: ScreenshotMode, _selector: Option<&str>) -> Result<Bytes> {
use crate::render::chrome_protocol::cdp::browser_protocol::page::CaptureScreenshotParams;
let mut params = CaptureScreenshotParams::default();
if matches!(mode, ScreenshotMode::FullPage) {
params.capture_beyond_viewport = Some(true);
}
let resp = self
.page
.execute(params)
.await
.map_err(|e| crate::Error::Render(format!("screenshot: {e}")))?;
let bytes_ref: &[u8] = resp.result.data.as_ref();
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bytes_ref)
.map_err(|e| crate::Error::Render(format!("screenshot decode: {e}")))?;
Ok(Bytes::from(decoded))
}
async fn inject_script(&self, src: &str) -> Result<()> {
use crate::render::chrome_protocol::cdp::browser_protocol::page::AddScriptToEvaluateOnNewDocumentParams;
let params = AddScriptToEvaluateOnNewDocumentParams {
source: src.to_string(),
world_name: None,
include_command_line_api: Some(false),
run_immediately: Some(true),
};
self.page
.execute(params)
.await
.map_err(|e| crate::Error::Render(format!("inject_script: {e}")))?;
Ok(())
}
async fn url(&self) -> Result<Option<String>> {
self.page
.url()
.await
.map_err(|e| crate::Error::Render(format!("url: {e}")))
}
async fn html(&self) -> Result<String> {
let v = self
.page
.evaluate("document.documentElement.outerHTML")
.await
.map_err(|e| crate::Error::Render(format!("html: {e}")))?;
Ok(v.value()
.and_then(|x| x.as_str())
.map(String::from)
.unwrap_or_default())
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub struct MockBrowserSession {
pub calls: parking_lot::Mutex<Vec<String>>,
}
impl MockBrowserSession {
pub fn new() -> Self {
Self {
calls: parking_lot::Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl BrowserSessionLike for MockBrowserSession {
async fn goto(&self, url: &Url) -> Result<()> {
self.calls.lock().push(format!("goto({url})"));
Ok(())
}
async fn eval_json(&self, expr: &str) -> Result<serde_json::Value> {
self.calls.lock().push(format!("eval({expr})"));
Ok(serde_json::Value::Null)
}
async fn screenshot(&self, mode: ScreenshotMode, selector: Option<&str>) -> Result<Bytes> {
self.calls
.lock()
.push(format!("screenshot({:?}, {:?})", mode, selector));
Ok(Bytes::new())
}
async fn inject_script(&self, src: &str) -> Result<()> {
self.calls
.lock()
.push(format!("inject_script({})", src.len()));
Ok(())
}
async fn url(&self) -> Result<Option<String>> {
self.calls.lock().push("url".into());
Ok(None)
}
async fn html(&self) -> Result<String> {
self.calls.lock().push("html".into());
Ok(String::new())
}
}
#[tokio::test]
async fn mock_records_calls() {
let mock = MockBrowserSession::new();
let url = Url::parse("https://example.com").unwrap();
mock.goto(&url).await.unwrap();
let _ = mock.eval_json("1+1").await.unwrap();
mock.inject_script("/* shim */").await.unwrap();
let calls = mock.calls.lock();
assert_eq!(calls.len(), 3);
assert!(calls[0].starts_with("goto"));
assert!(calls[1].starts_with("eval"));
assert!(calls[2].starts_with("inject_script"));
}
}